From af569b17bab57fbdb07fdb2bef35fa0c2f56431d Mon Sep 17 00:00:00 2001 From: yeyangtian <161981174@qq.com> Date: Tue, 28 Jul 2026 11:00:34 +0800 Subject: [PATCH 01/17] refactor(vue): remove hardcoded DEFAULT_SYMBOLS fallback data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 品种选择器不再内置默认品种列表,完全依赖 controller 的 symbolCatalog 信号从注册数据源获取,消除客户端硬编码兜底。 --- packages/vue/src/components/KLineChart.vue | 63 +--------------------- 1 file changed, 1 insertion(+), 62 deletions(-) diff --git a/packages/vue/src/components/KLineChart.vue b/packages/vue/src/components/KLineChart.vue index 531165c2..8034e523 100644 --- a/packages/vue/src/components/KLineChart.vue +++ b/packages/vue/src/components/KLineChart.vue @@ -255,7 +255,6 @@ type InteractionSnapshot, type LegendTemplateContext, type SymbolSpec, - type SymbolInfo, type CustomDataSource, } from '@363045841yyt/klinechart-core/controllers' import { @@ -425,63 +424,6 @@ 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') @@ -1669,10 +1611,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) From de086e0eb395429bf56874b0cd5804d8f19dcc03 Mon Sep 17 00:00:00 2001 From: yeyangtian <161981174@qq.com> Date: Tue, 28 Jul 2026 11:39:34 +0800 Subject: [PATCH 02/17] fix(timeshare): preserve original error message in Effect.tryPromise Default single-arg Effect.tryPromise wraps rejections in UnknownException, swallowing the original error message. Switch to { try, catch } overload to preserve Error/KLineChartError instances as-is. Add regression test verifying fetcher error messages appear in logs. --- .../data/__tests__/timeShareBuffer.test.ts | 23 +++++++++++++++ packages/core/src/data/timeShareBuffer.ts | 29 ++++++++++--------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/core/src/data/__tests__/timeShareBuffer.test.ts b/packages/core/src/data/__tests__/timeShareBuffer.test.ts index db6e96a4..c53f89b2 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('preserves the fetcher error message in Effect logs', async () => { + const buf = new TimeShareBuffer() + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + buf.setFetcher(async () => { + throw new Error('history-tick backend unavailable') + }) + + try { + buf.load({ symbol: '000001', period: 'timeshare', source: 'gotdx' }) + + await vi.waitFor( + () => { + const output = logSpy.mock.calls.flat().join(' ') + expect(output).toContain('history-tick backend unavailable') + }, + { timeout: 5_000 }, + ) + } finally { + buf.dispose() + logSpy.mockRestore() + } + }, 7_000) }) diff --git a/packages/core/src/data/timeShareBuffer.ts b/packages/core/src/data/timeShareBuffer.ts index 528690be..90f97a96 100644 --- a/packages/core/src/data/timeShareBuffer.ts +++ b/packages/core/src/data/timeShareBuffer.ts @@ -98,19 +98,22 @@ 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))), }), } From fcdbcafe0c6167269222ae2c5283c03cf63aba5f Mon Sep 17 00:00:00 2001 From: yeyangtian <161981174@qq.com> Date: Tue, 28 Jul 2026 13:15:34 +0800 Subject: [PATCH 03/17] feat(core): support market-aware symbol sessions Require normalized market identity across symbols, search results, caches, and semantic config. Add per-chart session registries and route gotdx Hong Kong time-share data without allowing unsupported search rows to poison valid results. Refs #105 --- ...-07-28-instance-market-session-registry.md | 80 +++++++++++ ...instance-market-session-registry-design.md | 125 ++++++++++++++++ packages/angular/src/index.ts | 2 + .../src/controllers/createChartController.ts | 2 +- packages/core/src/controllers/types.ts | 5 + .../data/__tests__/fetcherRegistry.test.ts | 33 +++++ .../core/src/data/__tests__/gotdx.test.ts | 135 ++++++++++++++++++ packages/core/src/data/gotdx.ts | 111 ++++++++++---- packages/core/src/data/router.ts | 2 +- packages/core/src/data/types.ts | 1 + .../__tests__/chart.marketValidation.test.ts | 105 ++++++++++++++ packages/core/src/engine/chart.ts | 39 ++++- .../chartDataManager.incrementalLoad.test.ts | 39 +++++ .../data/__tests__/comparisonManager.test.ts | 7 + .../core/src/engine/data/chartDataManager.ts | 37 ++--- .../core/src/engine/data/symbolIdentity.ts | 10 +- .../__tests__/marketSessionRegistry.test.ts | 47 ++++++ .../resolveSymbolMarketSession.test.ts | 33 +++++ .../engine/market/marketSessionRegistry.ts | 46 ++++++ .../market/resolveSymbolMarketSession.ts | 12 ++ .../core/src/engine/render/chartRenderer.ts | 6 + .../__tests__/timeAxis.marketSession.test.ts | 42 ++++++ .../core/src/engine/renderers/timeAxis.ts | 1 + .../semantic/__tests__/controller.test.ts | 2 + .../__tests__/validator.market.test.ts | 36 +++++ .../core/src/features/semantic/controller.ts | 1 + .../core/src/features/semantic/schema.json | 3 +- packages/core/src/features/semantic/types.ts | 4 +- packages/core/src/foundation/plugin/types.ts | 2 + packages/core/src/index.ts | 6 + packages/react/src/index.ts | 15 +- packages/vue/preview/App.vue | 1 + packages/vue/src/components/KLineChart.vue | 10 ++ .../vue/src/composables/useSymbolSearch.ts | 4 +- 34 files changed, 949 insertions(+), 55 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-instance-market-session-registry.md create mode 100644 docs/superpowers/specs/2026-07-28-instance-market-session-registry-design.md create mode 100644 packages/core/src/engine/__tests__/chart.marketValidation.test.ts create mode 100644 packages/core/src/engine/market/__tests__/marketSessionRegistry.test.ts create mode 100644 packages/core/src/engine/market/__tests__/resolveSymbolMarketSession.test.ts create mode 100644 packages/core/src/engine/market/marketSessionRegistry.ts create mode 100644 packages/core/src/engine/market/resolveSymbolMarketSession.ts create mode 100644 packages/core/src/engine/renderers/__tests__/timeAxis.marketSession.test.ts create mode 100644 packages/core/src/features/semantic/__tests__/validator.market.test.ts 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/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/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..8a7b92aa 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> // Pre-existing DOM elements (skip buildDom when provided) canvasLayer?: HTMLElement 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..27da1ff1 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,70 @@ 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('keeps supported results when the same response contains unsupported markets', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + { + symbol: '01810', + description: '小米集团-W', + exchange: 'HK', + source: 'gotdx', + params: { category: 31, kind: 'ex' }, + }, + { + symbol: '018100', + description: '太平恒泰3月定债A', + exchange: 'FUND', + source: 'gotdx', + params: { category: 33, kind: 'ex' }, + }, + ]), + ) + const definition = getRegisteredFetcher('gotdx') + + await expect(definition?.searcher?.('gotdx', { query: '01810' })).resolves.toEqual([ + expect.objectContaining({ symbol: '01810', market: 'HK' }), + ]) + }) + + it('rejects gotdx search metadata that cannot be normalized', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + { + symbol: 'IF2608', + description: '沪深300期货', + exchange: 'FUTURES', + source: 'gotdx', + params: { category: 47, kind: 'ex' }, + }, + ]), + ) + const definition = getRegisteredFetcher('gotdx') + + await expect(definition?.searcher?.('gotdx', { query: 'IF2608' })).rejects.toThrow( + /cannot normalize market.*IF2608/i, + ) + }) + it('uses params.market for stock requests', async () => { fetchMock.mockResolvedValue(jsonResponse([])) const definition = getRegisteredFetcher('gotdx') @@ -173,6 +238,76 @@ describe('gotdx fetcher', () => { ) }) + it('routes HK timeshare by params.category to ex/history-tick', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + preClose: 18.5, + data: [{ timestamp: '2026-07-24T09:30:00+08:00', Price: 18.6, Avg: 18.55, Vol: 100 }], + }), + ) + 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, + volume: 100, + amount: 18.6 * 100, + }, + ], + }) + }) + + 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/gotdx.ts b/packages/core/src/data/gotdx.ts index 97b52299..18199fe1 100644 --- a/packages/core/src/data/gotdx.ts +++ b/packages/core/src/data/gotdx.ts @@ -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', @@ -121,6 +95,56 @@ async function fetchGotdxHistoryTick( } } +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 new KLineChartError( + 'FETCH_FAILED', + `[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 new KLineChartError( + 'FETCH_FAILED', + `[gotdx] history-tick failed: ${res.status} ${res.statusText}`, + ) + return parseHistoryTickPayload(await res.json()) +} + async function searchGotdx( _source: string, config: SearchConfig, @@ -137,7 +161,38 @@ async function searchGotdx( `[gotdx] symbol search failed: ${res.status} ${res.statusText}`, ) } - return (await res.json()) as ReadonlyArray + const raw = (await res.json()) as ReadonlyArray> + const normalized: SearchResult[] = [] + let normalizationError: unknown + for (const item of raw) { + try { + normalized.push({ ...item, market: normalizeGotdxMarket(item) }) + } catch (error) { + normalizationError ??= error + } + } + if (normalized.length > 0 || raw.length === 0) return normalized + throw normalizationError +} + +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') { + if (item.exchange === 'HK') return 'HK' + if (item.exchange === 'US') return 'US' + } + + 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/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..a0fef3fc 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() @@ -1427,9 +1435,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 +1458,7 @@ export class Chart { } addComparisonSymbol(spec: SymbolSpec): void { + resolveSymbolMarketSession(spec, this.marketSessions) this.dataManager.addComparisonSymbol(spec) } @@ -1460,22 +1474,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..03f595b6 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,42 @@ 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) + }) }) 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..47f9d689 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 { @@ -223,8 +223,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() @@ -490,7 +490,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 +621,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 +643,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 +692,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 +702,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 +725,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 +747,7 @@ export class ChartDataManager { this.registerSymbols([ { symbol: symbolCode, + market: source.market, description: source.description ?? symbolCode, exchange: source.exchange ?? '', source: source.source ?? 'custom', @@ -813,7 +816,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 +841,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/render/chartRenderer.ts b/packages/core/src/engine/render/chartRenderer.ts index a4437dc7..f11d0159 100644 --- a/packages/core/src/engine/render/chartRenderer.ts +++ b/packages/core/src/engine/render/chartRenderer.ts @@ -932,6 +932,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 +966,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/__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/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/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/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/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..84f3bc62 100644 --- a/packages/vue/preview/App.vue +++ b/packages/vue/preview/App.vue @@ -629,6 +629,7 @@ if (useCustomData.value) { customData.value = { symbol: 'CUSTOM.DEMO', + market: 'CN', period: 'daily', data: DEMO_MAIN_DATA, comparisons: { diff --git a/packages/vue/src/components/KLineChart.vue b/packages/vue/src/components/KLineChart.vue index 8034e523..77db5204 100644 --- a/packages/vue/src/components/KLineChart.vue +++ b/packages/vue/src/components/KLineChart.vue @@ -252,6 +252,7 @@ routerSearchFetchers, getRegisteredFetchers, type ChartController, + type ChartMountOptions, type InteractionSnapshot, type LegendTemplateContext, type SymbolSpec, @@ -313,6 +314,9 @@ import MarkerTooltip from './MarkerTooltip.vue' /** 数据获取函数(可选)。默认使用内置 routerDataFetcher,亦可由使用者注入覆盖。 */ dataFetcher?: DataFetcher + /** 当前图表实例的市场交易时段覆盖 */ + marketSessions?: ChartMountOptions['marketSessions'] + yPaddingPx?: number minKWidth?: number maxKWidth?: number @@ -485,6 +489,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, @@ -1323,6 +1328,7 @@ import MarkerTooltip from './MarkerTooltip.vue' const ctrl = createChartController({ container, data: [], + marketSessions: props.marketSessions, canvasLayer, rightAxisLayer, leftAxisLayer, @@ -1434,6 +1440,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 ?? '', @@ -1444,6 +1451,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 ?? '', @@ -1465,6 +1473,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 ?? '', @@ -1484,6 +1493,7 @@ import MarkerTooltip from './MarkerTooltip.vue' ) return { symbol: s.symbol, + market: s.market, description: info?.description ?? s.symbol, exchange: s.exchange ?? '', source: s.source ?? '', diff --git a/packages/vue/src/composables/useSymbolSearch.ts b/packages/vue/src/composables/useSymbolSearch.ts index b5b1cb6e..4a98ba3e 100644 --- a/packages/vue/src/composables/useSymbolSearch.ts +++ b/packages/vue/src/composables/useSymbolSearch.ts @@ -12,6 +12,7 @@ import { export interface SearchableSymbol { symbol: string + market: string description: string exchange: string source: string @@ -20,6 +21,7 @@ export interface SearchableSymbol { export type SymbolIdentity = { symbol: string + market: string exchange?: string source?: string params?: Readonly> @@ -55,7 +57,7 @@ export function symbolIdentityKey(item: SymbolIdentity): string { const params = Object.entries(item.params ?? {}).sort(([left], [right]) => left.localeCompare(right), ) - return JSON.stringify([item.source ?? '', item.exchange ?? '', item.symbol, params]) + return JSON.stringify([item.source ?? '', item.market, item.exchange ?? '', item.symbol, params]) } export function uniqueSymbolsByIdentity(symbols: ReadonlyArray): T[] { From c50aecde82baf0442d96c16e0a34b77086bbe466 Mon Sep 17 00:00:00 2001 From: yeyangtian <161981174@qq.com> Date: Tue, 28 Jul 2026 13:30:45 +0800 Subject: [PATCH 04/17] fix(core): treat empty K-line response as warning, not error Empty data (e.g. ETF without gotdx history) is a valid server response. Downgrade from Effect.fail to logWarning so the chart renders empty instead of rejecting the fetch. --- packages/core/src/data/dataBuffer.effects.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) 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) ── From 10c8f6bc19970fdef0b22a8fb3fdda4c7e06737f Mon Sep 17 00:00:00 2001 From: yeyangtian <161981174@qq.com> Date: Tue, 28 Jul 2026 13:38:04 +0800 Subject: [PATCH 05/17] docs: design symbol chip fetch error title Specify buffer-owned lastError signal and native chip title for explicit K-line Effect failures. --- ...28-symbol-chip-fetch-error-title-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-28-symbol-chip-fetch-error-title-design.md 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..57e1e714 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-symbol-chip-fetch-error-title-design.md @@ -0,0 +1,105 @@ +# Symbol Chip Fetch Error Title Design + +## Goal + +When the main symbol K-line fetch fails with an explicit Effect error, the Vue symbol chip must show the failure reason on hover via the native `title` attribute. Users should not need the console to understand why the warning icon appears. + +## Scope + +- Main-symbol K-line fetch only. +- Propagate explicit Effect failures: network errors, HTTP/fetcher `FETCH_FAILED`, timeouts, missing source, and other rejected fetch promises after retries. +- Empty successful responses (`[]`) remain non-fatal warnings and do not set chip error reason. +- Native browser `title` only; no custom tooltip component. +- Out of scope for this change: TimeShareBuffer, comparison-symbol chips, search-result errors, custom popup UI. + +## Problem + +Today: + +1. `DataBuffer` catches fetch failures and only clears inflight state. +2. Vue `symbolStatus` becomes `'error'` when loading ends with no data. +3. `SymbolSelector` shows a warning icon for `error === true`. +4. Chip `title` is always the symbol display name, never the failure reason. + +The warning icon therefore has no user-facing explanation. + +## Design + +### Core: buffer-owned last error + +`DataBuffer` owns a writable error signal and exposes it as readonly: + +```ts +readonly lastError: ReadonlySignal +``` + +Rules: + +- On explicit fetch failure after Effect retry/timeout, set `lastError` to a human-readable message derived from the thrown value. +- Prefer `Error.message` when available; otherwise `String(error)`. +- On successful merge of any fetch result (including empty `[]`), clear `lastError` to `null`. +- On `setSymbol`, `setInlineData`, and `dispose`, clear `lastError` to `null`. +- Stale-request failures must not overwrite the current request's error or clear a newer request's success. + +`KLineBuffer` / `DataBufferLike` surface the same readonly signal so consumers do not cast to the concrete class. + +### Core: chart surface + +`ChartDataManager` and `Chart` expose: + +```ts +readonly dataError: ReadonlySignal +``` + +This reads the active primary K-line buffer's `lastError`. When no active K-line buffer exists, the value is `null`. + +No global EventBus path. Error state remains part of the data buffer lifecycle. + +### Vue: chip title + +`KLineChart` subscribes to `ctrl.dataError` (or equivalent controller exposure) and keeps a local `symbolErrorMessage: string | null`. + +Pass-through: + +1. `KLineChart` → `TopToolbar` as `symbolErrorMessage` +2. `TopToolbar` → `SymbolSelector` as `errorMessage` + +`SymbolSelector` chip title: + +- If `error && errorMessage`: use `errorMessage` +- Else: keep current `displayText` + +Warning icon continues to use the existing boolean `error` prop. This change does not invent a second visual state machine; it only supplies the reason text for hover. + +`symbolStatus === 'error'` may still be inferred from loading end without data for icon visibility. The title reason must come from `lastError` / `dataError`, not a hard-coded generic string, when an explicit Effect failure exists. + +If the icon is shown because data is empty but `lastError` is null (successful empty fetch), title may remain the symbol display name. Empty data is not an explicit Effect failure. + +## Message quality + +Do not invent new marketing copy in the UI layer. Surface the existing failure message from the Effect/fetcher boundary, for example: + +- `[gotdx] stock/kline-by-date failed: 500 Internal Server Error` +- `[DataBuffer] source is required for symbol "..."` +- timeout messages produced by Effect timeout + +If a message is empty after normalization, fall back to `加载失败`. + +## Testing + +- DataBuffer unit tests: + - failed fetch sets `lastError` to the error message + - successful fetch clears `lastError` + - `setSymbol` / `setInlineData` clear `lastError` + - empty successful `[]` does not set `lastError` + - stale rejected request does not clobber a newer successful request +- Vue wiring test or component-level assertion: + - when error message is provided, chip `title` equals that message + - when not in error, chip `title` remains display text + +## Non-goals + +- Custom styled tooltip +- Localizing/rewriting every fetcher message +- Comparison or timeshare error chips +- Changing empty-data policy back to hard failure From 617f6029ebfd08299535964b974a90ad682b8f90 Mon Sep 17 00:00:00 2001 From: yeyangtian <161981174@qq.com> Date: Tue, 28 Jul 2026 13:44:43 +0800 Subject: [PATCH 06/17] =?UTF-8?q?docs:=20=E4=B8=AD=E6=96=87=20chip=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=20title=20=E8=AE=BE=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-07-28-symbol-chip-fetch-error-title.md | 268 ++++++++++++++++++ ...28-symbol-chip-fetch-error-title-design.md | 120 ++++---- 2 files changed, 328 insertions(+), 60 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-symbol-chip-fetch-error-title.md 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-symbol-chip-fetch-error-title-design.md b/docs/superpowers/specs/2026-07-28-symbol-chip-fetch-error-title-design.md index 57e1e714..c1eb0e65 100644 --- 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 @@ -1,105 +1,105 @@ -# Symbol Chip Fetch Error Title Design +# 品种 Chip 拉取错误 Title 设计 -## Goal +## 目标 -When the main symbol K-line fetch fails with an explicit Effect error, the Vue symbol chip must show the failure reason on hover via the native `title` attribute. Users should not need the console to understand why the warning icon appears. +主品种 K 线拉取因 Effect 显式失败时,Vue 品种 chip 悬停须通过原生 `title` 显示失败原因。用户无需打开控制台即可理解警告图标含义。 -## Scope +## 范围 -- Main-symbol K-line fetch only. -- Propagate explicit Effect failures: network errors, HTTP/fetcher `FETCH_FAILED`, timeouts, missing source, and other rejected fetch promises after retries. -- Empty successful responses (`[]`) remain non-fatal warnings and do not set chip error reason. -- Native browser `title` only; no custom tooltip component. -- Out of scope for this change: TimeShareBuffer, comparison-symbol chips, search-result errors, custom popup UI. +- 仅主品种 K 线拉取。 +- 传播 Effect 显式失败:网络错误、HTTP/fetcher 的 `FETCH_FAILED`、超时、缺少 source,以及重试后仍 reject 的拉取 Promise。 +- 成功但返回空数组 `[]` 仍为非致命 warning,不写入 chip 错误原因。 +- 仅使用浏览器原生 `title`;不做自定义 tooltip 组件。 +- 本次不做:TimeShareBuffer、对比品种 chip、搜索结果错误、自定义气泡 UI。 -## Problem +## 问题 -Today: +现状: -1. `DataBuffer` catches fetch failures and only clears inflight state. -2. Vue `symbolStatus` becomes `'error'` when loading ends with no data. -3. `SymbolSelector` shows a warning icon for `error === true`. -4. Chip `title` is always the symbol display name, never the failure reason. +1. `DataBuffer` 捕获拉取失败后只清理 inflight 状态。 +2. Vue 的 `symbolStatus` 在 loading 结束且无数据时变为 `'error'`。 +3. `SymbolSelector` 在 `error === true` 时显示警告图标。 +4. chip 的 `title` 始终是品种展示名,从不显示失败原因。 -The warning icon therefore has no user-facing explanation. +因此警告图标没有面向用户的解释。 -## Design +## 设计 -### Core: buffer-owned last error +### Core:由 buffer 持有 lastError -`DataBuffer` owns a writable error signal and exposes it as readonly: +`DataBuffer` 内部维护可写错误信号,对外暴露只读: ```ts readonly lastError: ReadonlySignal ``` -Rules: +规则: -- On explicit fetch failure after Effect retry/timeout, set `lastError` to a human-readable message derived from the thrown value. -- Prefer `Error.message` when available; otherwise `String(error)`. -- On successful merge of any fetch result (including empty `[]`), clear `lastError` to `null`. -- On `setSymbol`, `setInlineData`, and `dispose`, clear `lastError` to `null`. -- Stale-request failures must not overwrite the current request's error or clear a newer request's success. +- Effect 在重试/超时后仍显式失败时,将 `lastError` 设为可读的错误 message。 +- 优先使用 `Error.message`;否则使用 `String(error)`。 +- 任意一次拉取成功 merge(含空 `[]`)时,将 `lastError` 清为 `null`。 +- 在 `setSymbol`、`setInlineData`、`dispose` 时将 `lastError` 清为 `null`。 +- 过期请求的失败不得覆盖当前请求的错误,也不得清除更新请求的成功状态。 -`KLineBuffer` / `DataBufferLike` surface the same readonly signal so consumers do not cast to the concrete class. +`KLineBuffer` / `DataBufferLike` 同步暴露同一只读信号,避免消费者向下转型。 -### Core: chart surface +### Core:Chart 对外表面 -`ChartDataManager` and `Chart` expose: +`ChartDataManager` 与 `Chart` 暴露: ```ts readonly dataError: ReadonlySignal ``` -This reads the active primary K-line buffer's `lastError`. When no active K-line buffer exists, the value is `null`. +读取当前主 K 线 buffer 的 `lastError`。无活动 K 线 buffer 时为 `null`。 -No global EventBus path. Error state remains part of the data buffer lifecycle. +不走全局 EventBus。错误状态留在 data buffer 生命周期内。 -### Vue: chip title +### Vue:chip title -`KLineChart` subscribes to `ctrl.dataError` (or equivalent controller exposure) and keeps a local `symbolErrorMessage: string | null`. +`KLineChart` 订阅 `ctrl.dataError`(或等价 controller 暴露),维护本地 `symbolErrorMessage: string | null`。 -Pass-through: +传递链路: -1. `KLineChart` → `TopToolbar` as `symbolErrorMessage` -2. `TopToolbar` → `SymbolSelector` as `errorMessage` +1. `KLineChart` → `TopToolbar` 的 `symbolErrorMessage` +2. `TopToolbar` → `SymbolSelector` 的 `errorMessage` -`SymbolSelector` chip title: +`SymbolSelector` chip title: -- If `error && errorMessage`: use `errorMessage` -- Else: keep current `displayText` +- 若 `error && errorMessage`:使用 `errorMessage` +- 否则:保持现有 `displayText` -Warning icon continues to use the existing boolean `error` prop. This change does not invent a second visual state machine; it only supplies the reason text for hover. +警告图标仍由现有布尔 `error` 控制。本改动不新增第二套视觉状态机,只为悬停提供原因文案。 -`symbolStatus === 'error'` may still be inferred from loading end without data for icon visibility. The title reason must come from `lastError` / `dataError`, not a hard-coded generic string, when an explicit Effect failure exists. +`symbolStatus === 'error'` 仍可由 loading 结束且无数据推断,用于图标可见性。title 原因在存在 Effect 显式失败时必须来自 `lastError` / `dataError`,不得写死通用文案。 -If the icon is shown because data is empty but `lastError` is null (successful empty fetch), title may remain the symbol display name. Empty data is not an explicit Effect failure. +若因空数据显示图标但 `lastError` 为 null(成功空拉取),title 可仍为品种展示名。空数据不是 Effect 显式失败。 -## Message quality +## 文案质量 -Do not invent new marketing copy in the UI layer. Surface the existing failure message from the Effect/fetcher boundary, for example: +UI 层不编造营销文案。直接透出 Effect/fetcher 边界已有的失败 message,例如: - `[gotdx] stock/kline-by-date failed: 500 Internal Server Error` - `[DataBuffer] source is required for symbol "..."` -- timeout messages produced by Effect timeout +- Effect timeout 产生的超时文案 -If a message is empty after normalization, fall back to `加载失败`. +规范化后 message 为空时,回退为 `加载失败`。 -## Testing +## 测试 -- DataBuffer unit tests: - - failed fetch sets `lastError` to the error message - - successful fetch clears `lastError` - - `setSymbol` / `setInlineData` clear `lastError` - - empty successful `[]` does not set `lastError` - - stale rejected request does not clobber a newer successful request -- Vue wiring test or component-level assertion: - - when error message is provided, chip `title` equals that message - - when not in error, chip `title` remains display text +- DataBuffer 单元测试: + - 失败拉取将 `lastError` 设为错误 message + - 成功拉取清空 `lastError` + - `setSymbol` / `setInlineData` 清空 `lastError` + - 成功空 `[]` 不设置 `lastError` + - 过期 reject 不覆盖更新成功请求 +- Vue 接线测试或组件级断言: + - 提供 error message 时 chip `title` 等于该文案 + - 非错误时 chip `title` 仍为展示名 -## Non-goals +## 非目标 -- Custom styled tooltip -- Localizing/rewriting every fetcher message -- Comparison or timeshare error chips -- Changing empty-data policy back to hard failure +- 自定义样式 tooltip +- 本地化/重写每条 fetcher message +- 对比或分时错误 chip +- 将空数据策略改回硬失败 From 7327e0635f3527d0322e9c211dc94b7c8a0747e2 Mon Sep 17 00:00:00 2001 From: yeyangtian <161981174@qq.com> Date: Tue, 28 Jul 2026 13:52:44 +0800 Subject: [PATCH 07/17] feat: show fetch error reason on symbol chip title Record DataBuffer lastError on explicit Effect failures, expose chart dataError, and use it as the SymbolSelector native title while the warning icon is shown. --- .../src/__tests__/executeTool.test.ts | 1 + .../angular/src/__tests__/_mockController.ts | 1 + .../src/controllers/createChartController.ts | 2 + packages/core/src/controllers/types.ts | 2 + .../src/data/__tests__/dataBuffer.test.ts | 47 ++++++++++++++++ packages/core/src/data/dataBuffer.ts | 54 +++++++++++++------ packages/core/src/data/dataBufferTypes.ts | 2 + packages/core/src/data/timeShareBuffer.ts | 6 +++ packages/core/src/engine/chart.ts | 5 ++ .../chartDataManager.incrementalLoad.test.ts | 35 ++++++++++++ .../core/src/engine/data/chartDataManager.ts | 15 ++++++ .../react/src/__tests__/_mockController.ts | 1 + packages/vue/src/__tests__/_mockController.ts | 1 + packages/vue/src/components/KLineChart.vue | 8 +++ .../vue/src/components/SymbolSelector.vue | 9 +++- packages/vue/src/components/TopToolbar.vue | 2 + .../SymbolSelector.errorTitle.test.ts | 50 +++++++++++++++++ 17 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 packages/vue/src/components/__tests__/SymbolSelector.errorTitle.test.ts 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/core/src/controllers/createChartController.ts b/packages/core/src/controllers/createChartController.ts index 8a7b92aa..32580c4d 100644 --- a/packages/core/src/controllers/createChartController.ts +++ b/packages/core/src/controllers/createChartController.ts @@ -376,6 +376,7 @@ export async function createChartController(opts: ChartMountOptions): Promise chart.indicators().map(mapIndicatorInstance)) @@ -908,6 +909,7 @@ export async function createChartController(opts: ChartMountOptions): Promise 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..b138ec75 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('does not set lastError for successful empty data', async () => { + buffer.setFetcher(async () => []) + buffer.setSymbol(defaultSpec) + 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) + 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/dataBuffer.ts b/packages/core/src/data/dataBuffer.ts index a8430943..fbbf0a53 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,29 @@ 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 + + this._lastError.set(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/timeShareBuffer.ts b/packages/core/src/data/timeShareBuffer.ts index 90f97a96..b48cf77f 100644 --- a/packages/core/src/data/timeShareBuffer.ts +++ b/packages/core/src/data/timeShareBuffer.ts @@ -17,6 +17,7 @@ export class TimeShareBuffer implements DataBufferLike { private _dataSignal: WritableSignal = createSignal({ data: [], prependedCount: 0 }) // 是否正在加载中,外部 UI 绑定用 private _loadingSignal: WritableSignal = createSignal(false) + private _lastError: WritableSignal = createSignal(null) // 可选的自定义 fetcher,优先级大于默认 fectcher private _fetcher: TimeShareFetcherFn | null = null // 指定查询的历史日期(0 = 当天) @@ -38,6 +39,11 @@ export class TimeShareBuffer implements DataBufferLike { return this._loadingSignal } + /** 分时暂不记录 lastError;满足 DataBufferLike 契约 */ + get lastError(): ReadonlySignal { + return this._lastError + } + get loadedWindow(): DataWindow | null { if (this._data.length === 0) return null return { diff --git a/packages/core/src/engine/chart.ts b/packages/core/src/engine/chart.ts index a0fef3fc..1eaffe7a 100644 --- a/packages/core/src/engine/chart.ts +++ b/packages/core/src/engine/chart.ts @@ -1335,6 +1335,11 @@ export class Chart { return this.dataManager.loading } + /** 主品种最近一次显式拉取失败原因 */ + get dataError(): ReadonlySignal { + return this.dataManager.dataError + } + /** 符号信号 */ get symbols(): ReadonlySignal> { return this.dataManager.symbols 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 03f595b6..851abbad 100644 --- a/packages/core/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts +++ b/packages/core/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts @@ -185,4 +185,39 @@ describe('ChartDataManager incremental load', () => { 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/chartDataManager.ts b/packages/core/src/engine/data/chartDataManager.ts index 47f9d689..6758f37d 100644 --- a/packages/core/src/engine/data/chartDataManager.ts +++ b/packages/core/src/engine/data/chartDataManager.ts @@ -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 } @@ -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 } 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/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 77db5204..18dbedb6 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" @@ -434,6 +435,7 @@ import MarkerTooltip from './MarkerTooltip.vue' 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([]) @@ -1410,6 +1412,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 @@ -1506,6 +1513,7 @@ import MarkerTooltip from './MarkerTooltip.vue' unsubscribeViewport() unsubscribeData() unsubscribeDataLoading() + unsubscribeDataError() unsubscribePaneRatios() unsubscribePaneLayout() unsubscribeTheme() diff --git a/packages/vue/src/components/SymbolSelector.vue b/packages/vue/src/components/SymbolSelector.vue index 621b2b37..16e5cc55 100644 --- a/packages/vue/src/components/SymbolSelector.vue +++ b/packages/vue/src/components/SymbolSelector.vue @@ -4,7 +4,7 @@ type="button" class="symbol-chip" :class="{ 'is-open': showPopup }" - :title="displayText" + :title="chipTitle" :aria-expanded="showPopup" aria-haspopup="dialog" @click="togglePopup" @@ -157,6 +157,8 @@ search?: SymbolSearchFn loading?: boolean error?: boolean + /** 主品种拉取失败原因;与 error 同时为真时作为 chip title */ + errorMessage?: string /** 已注册数据源,用于 Tabs 展示名 */ aggregationSources?: ReadonlyArray /** 已启用的搜索源名称 */ @@ -223,6 +225,11 @@ return props.symbol }) + const chipTitle = computed(() => { + if (props.error && props.errorMessage?.trim()) return props.errorMessage + return displayText.value + }) + const { results: filteredSymbols, loading: searchLoading, diff --git a/packages/vue/src/components/TopToolbar.vue b/packages/vue/src/components/TopToolbar.vue index 927bf8b9..86a17486 100644 --- a/packages/vue/src/components/TopToolbar.vue +++ b/packages/vue/src/components/TopToolbar.vue @@ -15,6 +15,7 @@ :search="search" :loading="symbolLoading" :error="symbolError" + :error-message="symbolErrorMessage" :aggregation-sources="aggregationSources" :enabled-source-names="enabledSourceNames" @change="onSymbolSelectorChange" @@ -125,6 +126,7 @@ search?: SymbolSearchFn symbolLoading?: boolean symbolError?: boolean + symbolErrorMessage?: string overlaySymbols?: string[] overlaySymbolItems?: SymbolItem[] comparisonColors?: Map diff --git a/packages/vue/src/components/__tests__/SymbolSelector.errorTitle.test.ts b/packages/vue/src/components/__tests__/SymbolSelector.errorTitle.test.ts new file mode 100644 index 00000000..fe0b4374 --- /dev/null +++ b/packages/vue/src/components/__tests__/SymbolSelector.errorTitle.test.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import SymbolSelector from '../SymbolSelector.vue' + +describe('SymbolSelector error title', () => { + it('uses errorMessage as chip title when error is true', () => { + const wrapper = mount(SymbolSelector, { + props: { + symbol: '158017', + symbols: [ + { + symbol: '158017', + market: 'CN', + description: '化工ETF易方达', + exchange: 'SZ', + source: 'gotdx', + }, + ], + error: true, + errorMessage: '[gotdx] stock/kline-by-date failed: 500', + }, + }) + + expect(wrapper.get('button.symbol-chip').attributes('title')).toBe( + '[gotdx] stock/kline-by-date failed: 500', + ) + }) + + it('keeps display text as title when not in error', () => { + const wrapper = mount(SymbolSelector, { + props: { + symbol: '158017', + symbols: [ + { + symbol: '158017', + market: 'CN', + description: '化工ETF易方达', + exchange: 'SZ', + source: 'gotdx', + }, + ], + error: false, + errorMessage: '[gotdx] stock/kline-by-date failed: 500', + }, + }) + + expect(wrapper.get('button.symbol-chip').attributes('title')).toBe('158017 - 化工ETF易方达') + }) +}) From 694cea2c3f4c39471a29c224c33ba2eab49c3ee7 Mon Sep 17 00:00:00 2001 From: yeyangtian <161981174@qq.com> Date: Tue, 28 Jul 2026 13:58:09 +0800 Subject: [PATCH 08/17] feat(vue): show symbol fetch error as rounded tag Place the warning icon and failure reason inside a pill-shaped tag on the symbol chip, with ellipsis and native title for overflow. --- .../vue/src/components/SymbolSelector.vue | 55 +++++++++++++++--- .../SymbolSelector.errorTitle.test.ts | 57 ++++++++++++------- 2 files changed, 81 insertions(+), 31 deletions(-) diff --git a/packages/vue/src/components/SymbolSelector.vue b/packages/vue/src/components/SymbolSelector.vue index 16e5cc55..6beb373f 100644 --- a/packages/vue/src/components/SymbolSelector.vue +++ b/packages/vue/src/components/SymbolSelector.vue @@ -4,14 +4,22 @@ type="button" class="symbol-chip" :class="{ 'is-open': showPopup }" - :title="chipTitle" + :title="displayText" :aria-expanded="showPopup" aria-haspopup="dialog" @click="togglePopup" > {{ displayText }}