@363045841yyt/klinechart-core 0.7.13 → 0.8.1-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/config/chartSettings.d.ts +0 -6
  2. package/dist/config/chartSettings.d.ts.map +1 -1
  3. package/dist/config/chartSettings.js +0 -1
  4. package/dist/config/chartSettings.js.map +1 -1
  5. package/dist/controllers/createChartController.d.ts.map +1 -1
  6. package/dist/controllers/createChartController.js +26 -0
  7. package/dist/controllers/createChartController.js.map +1 -1
  8. package/dist/controllers/index.d.ts +5 -3
  9. package/dist/controllers/index.d.ts.map +1 -1
  10. package/dist/controllers/index.js +3 -1
  11. package/dist/controllers/index.js.map +1 -1
  12. package/dist/controllers/types.d.ts +22 -0
  13. package/dist/controllers/types.d.ts.map +1 -1
  14. package/dist/data-fetchers/baostock.d.ts +3 -0
  15. package/dist/data-fetchers/baostock.d.ts.map +1 -0
  16. package/dist/data-fetchers/baostock.js +34 -0
  17. package/dist/data-fetchers/baostock.js.map +1 -0
  18. package/dist/data-fetchers/dataBuffer.d.ts +28 -0
  19. package/dist/data-fetchers/dataBuffer.d.ts.map +1 -0
  20. package/dist/data-fetchers/dataBuffer.js +150 -0
  21. package/dist/data-fetchers/dataBuffer.js.map +1 -0
  22. package/dist/data-fetchers/hundred-mock.d.ts +3 -0
  23. package/dist/data-fetchers/hundred-mock.d.ts.map +1 -0
  24. package/dist/data-fetchers/hundred-mock.js +30 -0
  25. package/dist/data-fetchers/hundred-mock.js.map +1 -0
  26. package/dist/data-fetchers/index.d.ts +7 -0
  27. package/dist/data-fetchers/index.d.ts.map +1 -0
  28. package/dist/data-fetchers/index.js +6 -0
  29. package/dist/data-fetchers/index.js.map +1 -0
  30. package/dist/data-fetchers/router.d.ts +3 -0
  31. package/dist/data-fetchers/router.d.ts.map +1 -0
  32. package/dist/data-fetchers/router.js +16 -0
  33. package/dist/data-fetchers/router.js.map +1 -0
  34. package/dist/data-fetchers/thousand-mock.d.ts +3 -0
  35. package/dist/data-fetchers/thousand-mock.d.ts.map +1 -0
  36. package/dist/data-fetchers/thousand-mock.js +29 -0
  37. package/dist/data-fetchers/thousand-mock.js.map +1 -0
  38. package/dist/engine/chart.d.ts +21 -0
  39. package/dist/engine/chart.d.ts.map +1 -1
  40. package/dist/engine/chart.js +113 -3
  41. package/dist/engine/chart.js.map +1 -1
  42. package/dist/engine/renderers/Indicator/{indicatorData.d.ts → indicatorCatalog.d.ts} +1 -1
  43. package/dist/engine/renderers/Indicator/indicatorCatalog.d.ts.map +1 -0
  44. package/dist/engine/renderers/Indicator/{indicatorData.js → indicatorCatalog.js} +94 -406
  45. package/dist/engine/renderers/Indicator/indicatorCatalog.js.map +1 -0
  46. package/dist/engine/renderers/Indicator/structure.js +1 -1
  47. package/dist/engine/renderers/Indicator/structure.js.map +1 -1
  48. package/dist/engine/renderers/Indicator/supertrend.js +1 -1
  49. package/dist/engine/renderers/Indicator/supertrend.js.map +1 -1
  50. package/dist/engine/renderers/paneTitle.d.ts.map +1 -1
  51. package/dist/engine/renderers/paneTitle.js +3 -2
  52. package/dist/engine/renderers/paneTitle.js.map +1 -1
  53. package/dist/engine/subPaneManager.d.ts.map +1 -1
  54. package/dist/engine/subPaneManager.js +2 -1
  55. package/dist/engine/subPaneManager.js.map +1 -1
  56. package/dist/semantic/controller.d.ts +3 -14
  57. package/dist/semantic/controller.d.ts.map +1 -1
  58. package/dist/semantic/controller.js +9 -43
  59. package/dist/semantic/controller.js.map +1 -1
  60. package/dist/semantic/index.d.ts +3 -2
  61. package/dist/semantic/index.d.ts.map +1 -1
  62. package/dist/semantic/index.js +1 -1
  63. package/dist/semantic/index.js.map +1 -1
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.d.ts.map +1 -1
  66. package/dist/version.js +1 -1
  67. package/dist/version.js.map +1 -1
  68. package/package.json +4 -4
  69. package/src/config/chartSettings.ts +0 -1
  70. package/src/controllers/__tests__/indicatorSelector.test.ts +1 -1
  71. package/src/controllers/createChartController.ts +35 -1
  72. package/src/controllers/index.ts +8 -2
  73. package/src/controllers/types.ts +31 -0
  74. package/src/data-fetchers/__tests__/dataBuffer.test.ts +287 -0
  75. package/src/data-fetchers/baostock.ts +34 -0
  76. package/src/data-fetchers/dataBuffer.ts +176 -0
  77. package/src/data-fetchers/hundred-mock.ts +31 -0
  78. package/src/data-fetchers/index.ts +6 -0
  79. package/src/data-fetchers/router.ts +17 -0
  80. package/src/data-fetchers/thousand-mock.ts +30 -0
  81. package/src/engine/chart.ts +128 -3
  82. package/src/engine/renderers/Indicator/indicatorCatalog.ts +346 -0
  83. package/src/engine/renderers/Indicator/structure.ts +1 -1
  84. package/src/engine/renderers/Indicator/supertrend.ts +1 -1
  85. package/src/engine/renderers/paneTitle.ts +3 -2
  86. package/src/engine/subPaneManager.ts +2 -1
  87. package/src/semantic/__tests__/controller.test.ts +19 -7
  88. package/src/semantic/controller.ts +9 -57
  89. package/src/semantic/index.ts +3 -2
  90. package/src/version.ts +1 -1
  91. package/dist/engine/renderers/Indicator/indicatorData.d.ts.map +0 -1
  92. package/dist/engine/renderers/Indicator/indicatorData.js.map +0 -1
  93. package/src/engine/renderers/Indicator/indicatorData.ts +0 -650
@@ -76,6 +76,31 @@ export interface KLineData {
76
76
 
77
77
  export type { PaneSpec }
78
78
 
79
+ // ---------------------------------------------------------------------------
80
+ // Symbol specification & DataFetcher adapter
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export interface SymbolSpec {
84
+ symbol: string
85
+ exchange?: string
86
+ period?: string
87
+ adjust?: string
88
+ source?: string
89
+ startDate?: string
90
+ endDate?: string
91
+ }
92
+
93
+ export type DataFetcher = (
94
+ source: string,
95
+ config: {
96
+ symbol: string
97
+ startDate: string
98
+ endDate: string
99
+ period: string
100
+ adjust: string
101
+ },
102
+ ) => Promise<ReadonlyArray<KLineData>>
103
+
79
104
  // ---------------------------------------------------------------------------
80
105
  // Indicator metadata
81
106
  // ---------------------------------------------------------------------------
@@ -183,6 +208,8 @@ export interface DrawingControllerCallbacks {
183
208
  export interface ChartMountOptions {
184
209
  container: HTMLElement
185
210
  data: ReadonlyArray<KLineData>
211
+ symbols?: ReadonlyArray<SymbolSpec>
212
+ dataFetcher?: DataFetcher
186
213
  initialZoomLevel?: number
187
214
  zoomLevels?: number
188
215
  theme?: 'light' | 'dark'
@@ -205,6 +232,8 @@ export interface ChartController extends DrawingChartAdapter {
205
232
  // ---- Signals ----
206
233
  readonly viewport: Signal<ChartViewport>
207
234
  readonly data: Signal<ReadonlyArray<KLineData>>
235
+ readonly dataLoading: Signal<boolean>
236
+ readonly symbols: Signal<ReadonlyArray<SymbolSpec>>
208
237
  readonly theme: Signal<'light' | 'dark'>
209
238
  readonly indicators: Signal<ReadonlyArray<IndicatorInstance>>
210
239
  readonly subPanes: Signal<ReadonlyArray<SubPaneInfo>>
@@ -218,6 +247,8 @@ export interface ChartController extends DrawingChartAdapter {
218
247
  readonly catalog: ReadonlyArray<IndicatorDefinition>
219
248
 
220
249
  // ---- Data ----
250
+ setSymbols(next: ReadonlyArray<SymbolSpec>): void
251
+ setDataFetcher(fetcher: DataFetcher | null): void
221
252
  setData(next: ReadonlyArray<KLineData>): void
222
253
  appendData(next: ReadonlyArray<KLineData>): void
223
254
  updateData(next: ReadonlyArray<KLineData>): void
@@ -0,0 +1,287 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { DataBuffer } from '../dataBuffer'
3
+ import type { DataFetcher, KLineData, SymbolSpec } from '../../controllers/types'
4
+
5
+ function makeKLine(ts: number): KLineData {
6
+ return {
7
+ timestamp: ts,
8
+ open: 100,
9
+ high: 110,
10
+ low: 90,
11
+ close: 105,
12
+ volume: 1000,
13
+ }
14
+ }
15
+
16
+ const MS_PER_DAY = 86_400_000
17
+
18
+ const defaultSpec: SymbolSpec = {
19
+ symbol: 'sh.600000',
20
+ period: 'daily',
21
+ adjust: 'none',
22
+ source: 'mock',
23
+ }
24
+
25
+ function makeMockFetcher(responses: Map<string, KLineData[]>): DataFetcher {
26
+ return async (source, config) => {
27
+ const key = `${config.symbol}_${config.startDate}_${config.endDate}`
28
+ return responses.get(key) ?? []
29
+ }
30
+ }
31
+
32
+ describe('DataBuffer', () => {
33
+ let buffer: DataBuffer
34
+
35
+ beforeEach(() => {
36
+ buffer = new DataBuffer()
37
+ })
38
+
39
+ it('initial state: empty data, not loading', () => {
40
+ expect(buffer.data()).toEqual([])
41
+ expect(buffer.loading()).toBe(false)
42
+ expect(buffer.loadedWindow).toBeNull()
43
+ })
44
+
45
+ it('setSymbol triggers initial load (now - 1 year)', async () => {
46
+ const now = Date.now()
47
+ const oneYearAgo = now - 365 * MS_PER_DAY
48
+ const fetchedData = [makeKLine(oneYearAgo + 86400000), makeKLine(now)]
49
+
50
+ let capturedConfig: { startDate: string; endDate: string } | null = null
51
+ const fetcher: DataFetcher = async (_source, config) => {
52
+ capturedConfig = config
53
+ return fetchedData
54
+ }
55
+
56
+ buffer.setFetcher(fetcher)
57
+ buffer.setSymbol(defaultSpec)
58
+
59
+ expect(buffer.loading()).toBe(true)
60
+
61
+ await vi.waitFor(() => {
62
+ expect(buffer.loading()).toBe(false)
63
+ })
64
+
65
+ expect(buffer.data()).toHaveLength(2)
66
+ expect(buffer.loadedWindow).not.toBeNull()
67
+ expect(buffer.loadedWindow!.earliestTs).toBe(fetchedData[0]!.timestamp)
68
+ expect(buffer.loadedWindow!.latestTs).toBe(fetchedData[1]!.timestamp)
69
+
70
+ expect(capturedConfig).not.toBeNull()
71
+ const startDate = new Date(capturedConfig!.startDate).getTime()
72
+ const endDate = new Date(capturedConfig!.endDate).getTime()
73
+ expect(endDate - startDate).toBeGreaterThan(364 * MS_PER_DAY)
74
+ expect(endDate - startDate).toBeLessThanOrEqual(366 * MS_PER_DAY)
75
+ })
76
+
77
+ it('ensureRange triggers incremental load when visible range is before loaded window', async () => {
78
+ const now = Date.now()
79
+ const oneYearAgo = now - 365 * MS_PER_DAY
80
+
81
+ const initialData = [makeKLine(oneYearAgo + MS_PER_DAY), makeKLine(now)]
82
+
83
+ let fetchCount = 0
84
+ const fetcher: DataFetcher = async () => {
85
+ fetchCount++
86
+ if (fetchCount === 1) return initialData
87
+ return [makeKLine(oneYearAgo - 90 * MS_PER_DAY), makeKLine(oneYearAgo)]
88
+ }
89
+
90
+ buffer.setFetcher(fetcher)
91
+ buffer.setSymbol(defaultSpec)
92
+
93
+ await vi.waitFor(() => {
94
+ expect(buffer.loading()).toBe(false)
95
+ })
96
+
97
+ expect(fetchCount).toBe(1)
98
+
99
+ const requestTs = oneYearAgo - 30 * MS_PER_DAY
100
+ buffer.ensureRange(requestTs, oneYearAgo)
101
+
102
+ expect(buffer.loading()).toBe(true)
103
+
104
+ await vi.waitFor(() => {
105
+ expect(buffer.loading()).toBe(false)
106
+ })
107
+
108
+ expect(fetchCount).toBe(2)
109
+ expect(buffer.data()).toHaveLength(4)
110
+ expect(buffer.loadedWindow!.earliestTs).toBe(oneYearAgo - 90 * MS_PER_DAY)
111
+ })
112
+
113
+ it('ensureRange does nothing when visible range is within loaded window', async () => {
114
+ const now = Date.now()
115
+ const oneYearAgo = now - 365 * MS_PER_DAY
116
+ const initialData = [makeKLine(oneYearAgo), makeKLine(now)]
117
+
118
+ let fetchCount = 0
119
+ const fetcher: DataFetcher = async () => {
120
+ fetchCount++
121
+ return initialData
122
+ }
123
+
124
+ buffer.setFetcher(fetcher)
125
+ buffer.setSymbol(defaultSpec)
126
+
127
+ await vi.waitFor(() => {
128
+ expect(buffer.loading()).toBe(false)
129
+ })
130
+
131
+ expect(fetchCount).toBe(1)
132
+
133
+ buffer.ensureRange(oneYearAgo + 100 * MS_PER_DAY, now)
134
+
135
+ expect(fetchCount).toBe(1)
136
+ })
137
+
138
+ it('merges data and deduplicates by timestamp', async () => {
139
+ const now = Date.now()
140
+ const oneYearAgo = now - 365 * MS_PER_DAY
141
+ const sharedTs = oneYearAgo + 100 * MS_PER_DAY
142
+
143
+ const initialData = [makeKLine(oneYearAgo), makeKLine(sharedTs), makeKLine(now)]
144
+ const incrementalData = [makeKLine(oneYearAgo - 90 * MS_PER_DAY), makeKLine(sharedTs)]
145
+
146
+ let fetchCount = 0
147
+ const fetcher: DataFetcher = async () => {
148
+ fetchCount++
149
+ if (fetchCount === 1) return initialData
150
+ return incrementalData
151
+ }
152
+
153
+ buffer.setFetcher(fetcher)
154
+ buffer.setSymbol(defaultSpec)
155
+
156
+ await vi.waitFor(() => {
157
+ expect(buffer.loading()).toBe(false)
158
+ })
159
+
160
+ buffer.ensureRange(oneYearAgo - 30 * MS_PER_DAY, oneYearAgo)
161
+
162
+ await vi.waitFor(() => {
163
+ expect(buffer.loading()).toBe(false)
164
+ })
165
+
166
+ const timestamps = buffer.data().map((d) => d.timestamp)
167
+ const uniqueTimestamps = new Set(timestamps)
168
+ expect(timestamps.length).toBe(uniqueTimestamps.size)
169
+ expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b))
170
+ })
171
+
172
+ it('queues concurrent ensureRange calls', async () => {
173
+ const now = Date.now()
174
+ const oneYearAgo = now - 365 * MS_PER_DAY
175
+
176
+ const initialData = [makeKLine(oneYearAgo + MS_PER_DAY), makeKLine(now)]
177
+ let fetchCount = 0
178
+ const fetcher: DataFetcher = async () => {
179
+ fetchCount++
180
+ await new Promise((r) => setTimeout(r, 10))
181
+ if (fetchCount === 1) return initialData
182
+ return [makeKLine(oneYearAgo - 90 * MS_PER_DAY)]
183
+ }
184
+
185
+ buffer.setFetcher(fetcher)
186
+ buffer.setSymbol(defaultSpec)
187
+
188
+ await vi.waitFor(() => {
189
+ expect(buffer.loading()).toBe(false)
190
+ })
191
+
192
+ buffer.ensureRange(oneYearAgo - 30 * MS_PER_DAY, oneYearAgo)
193
+ buffer.ensureRange(oneYearAgo - 60 * MS_PER_DAY, oneYearAgo)
194
+
195
+ await vi.waitFor(() => {
196
+ expect(buffer.loading()).toBe(false)
197
+ })
198
+
199
+ expect(fetchCount).toBeGreaterThanOrEqual(2)
200
+ })
201
+
202
+ it('dispose prevents further fetches', async () => {
203
+ const fetcher: DataFetcher = async () => {
204
+ return [makeKLine(Date.now())]
205
+ }
206
+
207
+ buffer.setFetcher(fetcher)
208
+ buffer.dispose()
209
+
210
+ expect(buffer.data()).toEqual([])
211
+ })
212
+
213
+ it('setSymbol resets data before loading', async () => {
214
+ const now = Date.now()
215
+ const fetcher: DataFetcher = async () => [makeKLine(now)]
216
+
217
+ buffer.setFetcher(fetcher)
218
+ buffer.setSymbol(defaultSpec)
219
+
220
+ await vi.waitFor(() => {
221
+ expect(buffer.loading()).toBe(false)
222
+ })
223
+
224
+ expect(buffer.data()).toHaveLength(1)
225
+
226
+ buffer.setSymbol({ ...defaultSpec, symbol: 'sz.000001' })
227
+
228
+ expect(buffer.data()).toEqual([])
229
+ expect(buffer.loadedWindow).toBeNull()
230
+
231
+ await vi.waitFor(() => {
232
+ expect(buffer.loading()).toBe(false)
233
+ })
234
+
235
+ expect(buffer.data()).toHaveLength(1)
236
+ })
237
+
238
+ it('onPrepend is called when data is prepended (earlier timestamps)', async () => {
239
+ const now = Date.now()
240
+ const oneYearAgo = now - 365 * MS_PER_DAY
241
+ const initialData = [makeKLine(oneYearAgo), makeKLine(now)]
242
+
243
+ let fetchCount = 0
244
+ const fetcher: DataFetcher = async () => {
245
+ fetchCount++
246
+ if (fetchCount === 1) return initialData
247
+ return [makeKLine(oneYearAgo - 90 * MS_PER_DAY), makeKLine(oneYearAgo - 45 * MS_PER_DAY)]
248
+ }
249
+
250
+ buffer.setFetcher(fetcher)
251
+ buffer.setSymbol(defaultSpec)
252
+
253
+ await vi.waitFor(() => {
254
+ expect(buffer.loading()).toBe(false)
255
+ })
256
+
257
+ const prependCalls: number[] = []
258
+ buffer.onPrepend = (count) => prependCalls.push(count)
259
+
260
+ buffer.ensureRange(oneYearAgo - 30 * MS_PER_DAY, oneYearAgo)
261
+
262
+ await vi.waitFor(() => {
263
+ expect(buffer.loading()).toBe(false)
264
+ })
265
+
266
+ expect(prependCalls).toHaveLength(1)
267
+ expect(prependCalls[0]).toBe(2)
268
+ })
269
+
270
+ it('onPrepend is not called for initial load', async () => {
271
+ const now = Date.now()
272
+ const oneYearAgo = now - 365 * MS_PER_DAY
273
+ const fetcher: DataFetcher = async () => [makeKLine(oneYearAgo), makeKLine(now)]
274
+
275
+ const prependCalls: number[] = []
276
+ buffer.onPrepend = (count) => prependCalls.push(count)
277
+
278
+ buffer.setFetcher(fetcher)
279
+ buffer.setSymbol(defaultSpec)
280
+
281
+ await vi.waitFor(() => {
282
+ expect(buffer.loading()).toBe(false)
283
+ })
284
+
285
+ expect(prependCalls).toHaveLength(0)
286
+ })
287
+ })
@@ -0,0 +1,34 @@
1
+ import type { DataFetcher, KLineData } from '../controllers/types'
2
+
3
+ export const baostockDataFetcher: DataFetcher = async (source, config) => {
4
+ console.log(`[baostock] fetching ${config.symbol} ${config.period} ${config.startDate}~${config.endDate}`)
5
+ const baseUrl = source === 'baostock' ? 'http://localhost:8000' : ''
6
+ const adjustMap: Record<string, string> = { qfq: '2', hfq: '1', none: '3' }
7
+ const periodMap: Record<string, string> = { daily: 'd', weekly: 'w', monthly: 'm', '5min': '5', '15min': '15', '30min': '30', '60min': '60' }
8
+ const adjustflag = adjustMap[config.adjust] ?? '3'
9
+ const url = `${baseUrl}/api/stock/kdata?stock_code=${config.symbol}&start_date=${config.startDate}&end_date=${config.endDate}&frequency=${periodMap[config.period] ?? 'd'}&adjustflag=${adjustflag}`
10
+ try {
11
+ const res = await fetch(url)
12
+ console.log(res)
13
+ if (!res.ok) {
14
+ console.warn(`[baostock] fetch failed: ${res.status} ${res.statusText}`)
15
+ return []
16
+ }
17
+ const json = await res.json()
18
+ console.log(json)
19
+ return (json.data ?? json).map((item: Record<string, unknown>) => ({
20
+ timestamp: new Date(item.date as string).getTime(),
21
+ open: Number(item.open),
22
+ high: Number(item.high),
23
+ low: Number(item.low),
24
+ close: Number(item.close),
25
+ volume: Number(item.volume),
26
+ turnover: Number(item.amount ?? 0),
27
+ turnoverRate: item.turn === '' ? 0 : Number(item.turn),
28
+ stockCode: String(item.code ?? config.symbol),
29
+ })) as KLineData[]
30
+ } catch (err) {
31
+ console.warn('[baostock] network error:', err)
32
+ return []
33
+ }
34
+ }
@@ -0,0 +1,176 @@
1
+ import { createSignal, type Signal } from '../reactivity/signal'
2
+ import type { DataFetcher, KLineData, SymbolSpec } from '../controllers/types'
3
+
4
+ export interface DataWindow {
5
+ earliestTs: number
6
+ latestTs: number
7
+ }
8
+
9
+ const MS_PER_DAY = 86_400_000
10
+ const INITIAL_LOAD_DAYS = 365
11
+ const INCREMENTAL_LOAD_DAYS = 90
12
+
13
+ function formatDate(ts: number): string {
14
+ const d = new Date(ts)
15
+ const y = d.getFullYear()
16
+ const m = String(d.getMonth() + 1).padStart(2, '0')
17
+ const day = String(d.getDate()).padStart(2, '0')
18
+ return `${y}-${m}-${day}`
19
+ }
20
+
21
+ function mergeSortedData(
22
+ existing: KLineData[],
23
+ incoming: KLineData[],
24
+ ): KLineData[] {
25
+ if (existing.length === 0) return [...incoming]
26
+ if (incoming.length === 0) return [...existing]
27
+
28
+ const tsSet = new Set<number>(existing.map((d) => d.timestamp))
29
+ const unique = incoming.filter((d) => !tsSet.has(d.timestamp))
30
+ if (unique.length === 0) return existing
31
+
32
+ const merged = [...existing, ...unique]
33
+ merged.sort((a, b) => a.timestamp - b.timestamp)
34
+ return merged
35
+ }
36
+
37
+ export class DataBuffer {
38
+ private _data: KLineData[] = []
39
+ private _dataSignal: Signal<ReadonlyArray<KLineData>>
40
+ private _loadingSignal: Signal<boolean>
41
+ private _fetcher: DataFetcher | null = null
42
+ private _currentSpec: SymbolSpec | null = null
43
+ private _loadedWindow: DataWindow | null = null
44
+ private _pendingFetch: Promise<void> | null = null
45
+ private _disposed = false
46
+
47
+ onPrepend: ((count: number) => void) | null = null
48
+
49
+ constructor() {
50
+ this._dataSignal = createSignal<ReadonlyArray<KLineData>>([])
51
+ this._loadingSignal = createSignal<boolean>(false)
52
+ }
53
+
54
+ get data(): Signal<ReadonlyArray<KLineData>> {
55
+ return this._dataSignal
56
+ }
57
+
58
+ get loading(): Signal<boolean> {
59
+ return this._loadingSignal
60
+ }
61
+
62
+ get loadedWindow(): DataWindow | null {
63
+ return this._loadedWindow
64
+ }
65
+
66
+ setFetcher(fetcher: DataFetcher | null): void {
67
+ this._fetcher = fetcher
68
+ }
69
+
70
+ setSymbol(spec: SymbolSpec): void {
71
+ this._currentSpec = spec
72
+ this._data = []
73
+ this._loadedWindow = null
74
+ this._dataSignal.set([])
75
+ this.loadInitial()
76
+ }
77
+
78
+ ensureRange(requestStartTs: number, _requestEndTs: number): void {
79
+ if (this._disposed || !this._fetcher || !this._currentSpec) return
80
+ if (!this._loadedWindow) return
81
+
82
+ if (requestStartTs >= this._loadedWindow.earliestTs) return
83
+
84
+ const incrementalStart = requestStartTs - INCREMENTAL_LOAD_DAYS * MS_PER_DAY
85
+ const incrementalEnd = this._loadedWindow.earliestTs
86
+
87
+ if (incrementalEnd <= incrementalStart) return
88
+
89
+ this.fetchRange(incrementalStart, incrementalEnd)
90
+ }
91
+
92
+ private loadInitial(): void {
93
+ if (!this._fetcher || !this._currentSpec || this._disposed) return
94
+
95
+ const now = Date.now()
96
+ const startDate = now - INITIAL_LOAD_DAYS * MS_PER_DAY
97
+ const endDate = now
98
+
99
+ this.fetchRange(startDate, endDate)
100
+ }
101
+
102
+ private fetchRange(startTs: number, endTs: number): void {
103
+ if (!this._fetcher || !this._currentSpec || this._disposed) return
104
+
105
+ if (this._pendingFetch) {
106
+ this._pendingFetch = this._pendingFetch.then(() => {
107
+ if (this._disposed) return
108
+ this.fetchRange(startTs, endTs)
109
+ })
110
+ return
111
+ }
112
+
113
+ const spec = this._currentSpec
114
+ const fetcher = this._fetcher
115
+
116
+ this._loadingSignal.set(true)
117
+
118
+ this._pendingFetch = fetcher(spec.source ?? 'baostock', {
119
+ symbol: spec.symbol,
120
+ startDate: formatDate(startTs),
121
+ endDate: formatDate(endTs),
122
+ period: spec.period ?? 'daily',
123
+ adjust: spec.adjust ?? 'none',
124
+ })
125
+ .then((incoming) => {
126
+ if (this._disposed) return
127
+
128
+ const oldLength = this._data.length
129
+ const oldEarliestTs = oldLength > 0 ? this._data[0]!.timestamp : null
130
+ const merged = mergeSortedData(this._data, [...incoming])
131
+
132
+ if (oldLength > 0 && merged.length > oldLength && oldEarliestTs !== null) {
133
+ const newEarliestTs = merged[0]!.timestamp
134
+ if (newEarliestTs < oldEarliestTs) {
135
+ const prependCount = merged.findIndex((d) => d.timestamp === oldEarliestTs)
136
+ if (prependCount > 0) {
137
+ this.onPrepend?.(prependCount)
138
+ }
139
+ }
140
+ }
141
+
142
+ this._data = merged
143
+ this._dataSignal.set([...merged])
144
+
145
+ if (merged.length > 0) {
146
+ const newEarliest = merged[0]!.timestamp
147
+ const newLatest = merged[merged.length - 1]!.timestamp
148
+ if (!this._loadedWindow) {
149
+ this._loadedWindow = { earliestTs: newEarliest, latestTs: newLatest }
150
+ } else {
151
+ this._loadedWindow = {
152
+ earliestTs: Math.min(this._loadedWindow.earliestTs, newEarliest),
153
+ latestTs: Math.max(this._loadedWindow.latestTs, newLatest),
154
+ }
155
+ }
156
+ }
157
+ })
158
+ .catch((err) => {
159
+ if (this._disposed) return
160
+ console.error('[DataBuffer] fetch failed:', err)
161
+ })
162
+ .finally(() => {
163
+ this._pendingFetch = null
164
+ if (!this._disposed) {
165
+ this._loadingSignal.set(false)
166
+ }
167
+ })
168
+ }
169
+
170
+ dispose(): void {
171
+ this._disposed = true
172
+ this._pendingFetch = null
173
+ this._data = []
174
+ this._loadedWindow = null
175
+ }
176
+ }
@@ -0,0 +1,31 @@
1
+ import type { DataFetcher, KLineData } from '../controllers/types'
2
+
3
+ export const hundredMockDataFetcher: DataFetcher = async (_source, config) => {
4
+ console.log(`[hundred-mock] generating ${config.symbol} ${config.period}`)
5
+ const start = new Date(config.startDate).getTime()
6
+ const end = new Date(config.endDate).getTime()
7
+ const dayMs = 86400000
8
+ const totalDays = Math.floor((end - start) / dayMs) + 1
9
+ const data: KLineData[] = []
10
+ let price = 10 + Math.random() * 5
11
+ for (let i = 0; i < totalDays; i++) {
12
+ const ts = start + i * dayMs
13
+ const change = (Math.random() - 0.48) * price * 0.06
14
+ const open = price
15
+ const close = Math.round((open + change) * 100) / 100
16
+ const high = Math.round(Math.max(open, close) * (1 + Math.random() * 0.03) * 100) / 100
17
+ const low = Math.round(Math.min(open, close) * (1 - Math.random() * 0.03) * 100) / 100
18
+ const volume = Math.round(Math.random() * 10000000 + 1000000)
19
+ data.push({
20
+ timestamp: ts,
21
+ open,
22
+ high,
23
+ low,
24
+ close,
25
+ volume,
26
+ turnover: Math.round((volume * (open + close)) / 2),
27
+ })
28
+ price = close
29
+ }
30
+ return data
31
+ }
@@ -0,0 +1,6 @@
1
+ export { thousandMockDataFetcher } from './thousand-mock'
2
+ export { hundredMockDataFetcher } from './hundred-mock'
3
+ export { baostockDataFetcher } from './baostock'
4
+ export { routerDataFetcher } from './router'
5
+ export { DataBuffer } from './dataBuffer'
6
+ export type { DataWindow } from './dataBuffer'
@@ -0,0 +1,17 @@
1
+ import type { DataFetcher } from '../controllers/types'
2
+ import { baostockDataFetcher } from './baostock'
3
+ import { hundredMockDataFetcher } from './hundred-mock'
4
+ import { thousandMockDataFetcher } from './thousand-mock'
5
+
6
+ export const routerDataFetcher: DataFetcher = (source, config) => {
7
+ switch (source) {
8
+ case 'baostock':
9
+ return baostockDataFetcher(source, config)
10
+ case 'mock-100':
11
+ return hundredMockDataFetcher(source, config)
12
+ case 'mock-10000':
13
+ return thousandMockDataFetcher(source, config)
14
+ default:
15
+ return hundredMockDataFetcher(source, config)
16
+ }
17
+ }
@@ -0,0 +1,30 @@
1
+ import type { DataFetcher, KLineData } from '../controllers/types'
2
+
3
+ export const thousandMockDataFetcher: DataFetcher = async (_source, _config) => {
4
+ console.log('[thousand-mock] generating 10k K-lines')
5
+ const data: KLineData[] = []
6
+ const startTime = new Date('2020-01-01').getTime()
7
+ const dayMs = 24 * 60 * 60 * 1000
8
+ let lastClose = 3000
9
+ for (let i = 0; i < 10000; i++) {
10
+ const timestamp = startTime + i * dayMs
11
+ const volatility = 0.02
12
+ const trend = 0.0001
13
+ const change = (Math.random() - 0.5) * 2 * volatility + trend
14
+ const open = lastClose
15
+ const close = open * (1 + change)
16
+ const high = Math.max(open, close) * (1 + Math.random() * 0.01)
17
+ const low = Math.min(open, close) * (1 - Math.random() * 0.01)
18
+ const volume = Math.floor(1000000 + Math.random() * 5000000)
19
+ data.push({
20
+ timestamp,
21
+ open: parseFloat(open.toFixed(2)),
22
+ high: parseFloat(high.toFixed(2)),
23
+ low: parseFloat(low.toFixed(2)),
24
+ close: parseFloat(close.toFixed(2)),
25
+ volume,
26
+ })
27
+ lastClose = close
28
+ }
29
+ return data
30
+ }