@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.
- package/dist/config/chartSettings.d.ts +0 -6
- package/dist/config/chartSettings.d.ts.map +1 -1
- package/dist/config/chartSettings.js +0 -1
- package/dist/config/chartSettings.js.map +1 -1
- package/dist/controllers/createChartController.d.ts.map +1 -1
- package/dist/controllers/createChartController.js +26 -0
- package/dist/controllers/createChartController.js.map +1 -1
- package/dist/controllers/index.d.ts +5 -3
- package/dist/controllers/index.d.ts.map +1 -1
- package/dist/controllers/index.js +3 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/types.d.ts +22 -0
- package/dist/controllers/types.d.ts.map +1 -1
- package/dist/data-fetchers/baostock.d.ts +3 -0
- package/dist/data-fetchers/baostock.d.ts.map +1 -0
- package/dist/data-fetchers/baostock.js +34 -0
- package/dist/data-fetchers/baostock.js.map +1 -0
- package/dist/data-fetchers/dataBuffer.d.ts +28 -0
- package/dist/data-fetchers/dataBuffer.d.ts.map +1 -0
- package/dist/data-fetchers/dataBuffer.js +150 -0
- package/dist/data-fetchers/dataBuffer.js.map +1 -0
- package/dist/data-fetchers/hundred-mock.d.ts +3 -0
- package/dist/data-fetchers/hundred-mock.d.ts.map +1 -0
- package/dist/data-fetchers/hundred-mock.js +30 -0
- package/dist/data-fetchers/hundred-mock.js.map +1 -0
- package/dist/data-fetchers/index.d.ts +7 -0
- package/dist/data-fetchers/index.d.ts.map +1 -0
- package/dist/data-fetchers/index.js +6 -0
- package/dist/data-fetchers/index.js.map +1 -0
- package/dist/data-fetchers/router.d.ts +3 -0
- package/dist/data-fetchers/router.d.ts.map +1 -0
- package/dist/data-fetchers/router.js +16 -0
- package/dist/data-fetchers/router.js.map +1 -0
- package/dist/data-fetchers/thousand-mock.d.ts +3 -0
- package/dist/data-fetchers/thousand-mock.d.ts.map +1 -0
- package/dist/data-fetchers/thousand-mock.js +29 -0
- package/dist/data-fetchers/thousand-mock.js.map +1 -0
- package/dist/engine/chart.d.ts +21 -0
- package/dist/engine/chart.d.ts.map +1 -1
- package/dist/engine/chart.js +113 -3
- package/dist/engine/chart.js.map +1 -1
- package/dist/engine/renderers/Indicator/{indicatorData.d.ts → indicatorCatalog.d.ts} +1 -1
- package/dist/engine/renderers/Indicator/indicatorCatalog.d.ts.map +1 -0
- package/dist/engine/renderers/Indicator/{indicatorData.js → indicatorCatalog.js} +94 -406
- package/dist/engine/renderers/Indicator/indicatorCatalog.js.map +1 -0
- package/dist/engine/renderers/Indicator/structure.js +1 -1
- package/dist/engine/renderers/Indicator/structure.js.map +1 -1
- package/dist/engine/renderers/Indicator/supertrend.js +1 -1
- package/dist/engine/renderers/Indicator/supertrend.js.map +1 -1
- package/dist/engine/renderers/paneTitle.d.ts.map +1 -1
- package/dist/engine/renderers/paneTitle.js +3 -2
- package/dist/engine/renderers/paneTitle.js.map +1 -1
- package/dist/engine/subPaneManager.d.ts.map +1 -1
- package/dist/engine/subPaneManager.js +2 -1
- package/dist/engine/subPaneManager.js.map +1 -1
- package/dist/semantic/controller.d.ts +3 -14
- package/dist/semantic/controller.d.ts.map +1 -1
- package/dist/semantic/controller.js +9 -43
- package/dist/semantic/controller.js.map +1 -1
- package/dist/semantic/index.d.ts +3 -2
- package/dist/semantic/index.d.ts.map +1 -1
- package/dist/semantic/index.js +1 -1
- package/dist/semantic/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +4 -4
- package/src/config/chartSettings.ts +0 -1
- package/src/controllers/__tests__/indicatorSelector.test.ts +1 -1
- package/src/controllers/createChartController.ts +35 -1
- package/src/controllers/index.ts +8 -2
- package/src/controllers/types.ts +31 -0
- package/src/data-fetchers/__tests__/dataBuffer.test.ts +287 -0
- package/src/data-fetchers/baostock.ts +34 -0
- package/src/data-fetchers/dataBuffer.ts +176 -0
- package/src/data-fetchers/hundred-mock.ts +31 -0
- package/src/data-fetchers/index.ts +6 -0
- package/src/data-fetchers/router.ts +17 -0
- package/src/data-fetchers/thousand-mock.ts +30 -0
- package/src/engine/chart.ts +128 -3
- package/src/engine/renderers/Indicator/indicatorCatalog.ts +346 -0
- package/src/engine/renderers/Indicator/structure.ts +1 -1
- package/src/engine/renderers/Indicator/supertrend.ts +1 -1
- package/src/engine/renderers/paneTitle.ts +3 -2
- package/src/engine/subPaneManager.ts +2 -1
- package/src/semantic/__tests__/controller.test.ts +19 -7
- package/src/semantic/controller.ts +9 -57
- package/src/semantic/index.ts +3 -2
- package/src/version.ts +1 -1
- package/dist/engine/renderers/Indicator/indicatorData.d.ts.map +0 -1
- package/dist/engine/renderers/Indicator/indicatorData.js.map +0 -1
- package/src/engine/renderers/Indicator/indicatorData.ts +0 -650
package/src/controllers/types.ts
CHANGED
|
@@ -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
|
+
}
|