@363045841yyt/klinechart-core 0.9.0-alpha.3 → 0.9.0-alpha.4
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/controllers/index.d.ts +1 -1
- package/dist/controllers/index.d.ts.map +1 -1
- package/dist/data/timeShareBuffer.d.ts +3 -0
- package/dist/data/timeShareBuffer.d.ts.map +1 -1
- package/dist/data/timeShareBuffer.js +13 -0
- package/dist/data/timeShareBuffer.js.map +1 -1
- package/dist/engine/chart.d.ts.map +1 -1
- package/dist/engine/chart.js +18 -5
- package/dist/engine/chart.js.map +1 -1
- package/dist/engine/data/chartDataManager.d.ts +2 -0
- package/dist/engine/data/chartDataManager.d.ts.map +1 -1
- package/dist/engine/data/chartDataManager.js +9 -0
- package/dist/engine/data/chartDataManager.js.map +1 -1
- package/dist/engine/modes/timeShareMath.d.ts +56 -0
- package/dist/engine/modes/timeShareMath.d.ts.map +1 -0
- package/dist/engine/modes/timeShareMath.js +90 -0
- package/dist/engine/modes/timeShareMath.js.map +1 -0
- package/dist/engine/modes/timeShareMode.d.ts +5 -0
- package/dist/engine/modes/timeShareMode.d.ts.map +1 -1
- package/dist/engine/modes/timeShareMode.js +27 -27
- package/dist/engine/modes/timeShareMode.js.map +1 -1
- package/dist/engine/render/chartRenderer.d.ts.map +1 -1
- package/dist/engine/render/chartRenderer.js +27 -17
- package/dist/engine/render/chartRenderer.js.map +1 -1
- package/dist/engine/renderers/Indicator/mainIndicatorLegend.js +2 -2
- package/dist/engine/renderers/Indicator/mainIndicatorLegend.js.map +1 -1
- package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.d.ts +4 -3
- package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.d.ts.map +1 -1
- package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.js +13 -8
- package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.js.map +1 -1
- package/dist/engine/renderers/timeShare.d.ts.map +1 -1
- package/dist/engine/renderers/timeShare.js +20 -9
- package/dist/engine/renderers/timeShare.js.map +1 -1
- package/dist/foundation/config/chartSettings.d.ts +0 -5
- package/dist/foundation/config/chartSettings.d.ts.map +1 -1
- package/dist/foundation/config/chartSettings.js +14 -0
- package/dist/foundation/config/chartSettings.js.map +1 -1
- package/dist/foundation/utils/kLineDraw/axis.d.ts +3 -0
- package/dist/foundation/utils/kLineDraw/axis.d.ts.map +1 -1
- package/dist/foundation/utils/kLineDraw/axis.js +40 -9
- package/dist/foundation/utils/kLineDraw/axis.js.map +1 -1
- package/dist/foundation/utils/sessionTimeLabels.d.ts +59 -0
- package/dist/foundation/utils/sessionTimeLabels.d.ts.map +1 -0
- package/dist/foundation/utils/sessionTimeLabels.js +170 -0
- package/dist/foundation/utils/sessionTimeLabels.js.map +1 -0
- package/dist/foundation/utils/timeShareAxisLabels.d.ts +27 -0
- package/dist/foundation/utils/timeShareAxisLabels.d.ts.map +1 -0
- package/dist/foundation/utils/timeShareAxisLabels.js +45 -0
- package/dist/foundation/utils/timeShareAxisLabels.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/controllers/index.ts +1 -1
- package/src/data/__tests__/timeShareBuffer.test.ts +45 -0
- package/src/data/timeShareBuffer.ts +15 -0
- package/src/engine/__tests__/chart.dpr.test.ts +28 -0
- package/src/engine/chart.ts +29 -6
- package/src/engine/data/chartDataManager.ts +10 -0
- package/src/engine/modes/__tests__/timeShareMath.test.ts +185 -0
- package/src/engine/modes/__tests__/timeShareMode.test.ts +81 -0
- package/src/engine/modes/timeShareMath.ts +150 -0
- package/src/engine/modes/timeShareMode.ts +35 -28
- package/src/engine/render/chartRenderer.ts +29 -18
- package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +2 -2
- package/src/engine/renderers/Indicator/mainIndicatorLegendContext.ts +19 -11
- package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +4 -4
- package/src/engine/renderers/timeShare.ts +25 -9
- package/src/engine/state/__tests__/settingsState.test.ts +9 -0
- package/src/foundation/config/chartSettings.ts +13 -0
- package/src/foundation/utils/__tests__/sessionTimeLabels.test.ts +91 -0
- package/src/foundation/utils/kLineDraw/axis.ts +50 -9
- package/src/foundation/utils/sessionTimeLabels.ts +237 -0
- package/src/foundation/utils/timeShareAxisLabels.ts +94 -0
- package/src/version.ts +1 -1
|
@@ -224,8 +224,8 @@ function paintLegendOnCanvas(overlayCtx: CanvasRenderingContext2D, legend: Legen
|
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
if (legend.
|
|
228
|
-
const k = legend.
|
|
227
|
+
if (legend.currentBar) {
|
|
228
|
+
const k = legend.currentBar
|
|
229
229
|
if (!compact) {
|
|
230
230
|
let x = legendX
|
|
231
231
|
const y = rowY()
|
|
@@ -17,8 +17,9 @@ export interface LegendLayout {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** 当前 K 线及图例派生的展示字段,保留 KLineData 自定义属性。 */
|
|
20
|
-
export type
|
|
20
|
+
export type LegendCurrentBar = Omit<KLineData, 'volume'> & {
|
|
21
21
|
volume: number | null
|
|
22
|
+
// 成交量+单位格式化文本(eg. 1.23亿)
|
|
22
23
|
volumeText: string | null
|
|
23
24
|
color: string
|
|
24
25
|
}
|
|
@@ -63,11 +64,12 @@ export interface LegendTemplateContext {
|
|
|
63
64
|
up: string
|
|
64
65
|
down: string
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
+
/** 十字线指向的当前 K 线展示行(含 volumeText / color 与自定义字段) */
|
|
68
|
+
currentBar: LegendCurrentBar | null
|
|
67
69
|
timeshare: LegendTimeshareRow | null
|
|
68
70
|
indicators: ReadonlyArray<LegendIndicatorRow>
|
|
69
71
|
comparisons: ReadonlyArray<LegendComparisonRow>
|
|
70
|
-
/**
|
|
72
|
+
/** 当前索引处的原始 K 线(分时模式下可能无 close) */
|
|
71
73
|
bar: KLineData | TimeShareData | null
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -110,9 +112,7 @@ export function buildLegendTemplateContext(
|
|
|
110
112
|
const range = context.range
|
|
111
113
|
const crosshairIndex = context.crosshairIndex
|
|
112
114
|
const hasCrosshair = typeof crosshairIndex === 'number'
|
|
113
|
-
const targetIndex = hasCrosshair
|
|
114
|
-
? crosshairIndex
|
|
115
|
-
: Math.min(range.end - 1, klineData.length - 1)
|
|
115
|
+
const targetIndex = hasCrosshair ? crosshairIndex : Math.min(range.end - 1, klineData.length - 1)
|
|
116
116
|
|
|
117
117
|
const layout: LegendLayout = {
|
|
118
118
|
x: legendX,
|
|
@@ -126,7 +126,11 @@ export function buildLegendTemplateContext(
|
|
|
126
126
|
let timeshare: LegendTimeshareRow | null = null
|
|
127
127
|
if (context.period === 'timeshare') {
|
|
128
128
|
const tsData = context.data as TimeShareData[]
|
|
129
|
-
const
|
|
129
|
+
const rawPreClose = context.settings?.preClose as number | undefined
|
|
130
|
+
const preClose =
|
|
131
|
+
typeof rawPreClose === 'number' && Number.isFinite(rawPreClose) && rawPreClose !== 0
|
|
132
|
+
? rawPreClose
|
|
133
|
+
: (tsData[0]?.price ?? 0)
|
|
130
134
|
const item = tsData[targetIndex]
|
|
131
135
|
if (item) {
|
|
132
136
|
const changeAmount = item.price - preClose
|
|
@@ -145,12 +149,12 @@ export function buildLegendTemplateContext(
|
|
|
145
149
|
}
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
let
|
|
152
|
+
let currentBar: LegendCurrentBar | null = null
|
|
149
153
|
if (hasCrosshair) {
|
|
150
154
|
const k = klineData[targetIndex]
|
|
151
155
|
if (k && typeof k.close === 'number') {
|
|
152
156
|
const isUp = k.close >= k.open
|
|
153
|
-
|
|
157
|
+
currentBar = {
|
|
154
158
|
...k,
|
|
155
159
|
volume: typeof k.volume === 'number' ? k.volume : null,
|
|
156
160
|
volumeText: typeof k.volume === 'number' ? formatVolumeShort(k.volume) : null,
|
|
@@ -173,7 +177,7 @@ export function buildLegendTemplateContext(
|
|
|
173
177
|
up: colors.candleUpBody,
|
|
174
178
|
down: colors.candleDownBody,
|
|
175
179
|
},
|
|
176
|
-
|
|
180
|
+
currentBar,
|
|
177
181
|
timeshare,
|
|
178
182
|
indicators,
|
|
179
183
|
comparisons,
|
|
@@ -258,7 +262,11 @@ function collectComparisonRows(
|
|
|
258
262
|
percent,
|
|
259
263
|
color,
|
|
260
264
|
percentColor:
|
|
261
|
-
percent > 0
|
|
265
|
+
percent > 0
|
|
266
|
+
? colors.candleUpBody
|
|
267
|
+
: percent < 0
|
|
268
|
+
? colors.candleDownBody
|
|
269
|
+
: colors.text.primary,
|
|
262
270
|
})
|
|
263
271
|
}
|
|
264
272
|
return rows
|
|
@@ -574,7 +574,7 @@ describe('MainIndicatorLegend external mode & context callback', () => {
|
|
|
574
574
|
expect(legend).not.toBeNull()
|
|
575
575
|
expect(legend.index).toBe(50)
|
|
576
576
|
expect(legend.hasCrosshair).toBe(true)
|
|
577
|
-
expect(legend.
|
|
577
|
+
expect(legend.currentBar).not.toBeNull()
|
|
578
578
|
expect(legend.indicators.some((row: { name: string }) => row.name === 'MA')).toBe(true)
|
|
579
579
|
expect(vi.mocked(ctx.fillText).mock.calls.length).toBeGreaterThan(0)
|
|
580
580
|
})
|
|
@@ -601,7 +601,7 @@ describe('MainIndicatorLegend external mode & context callback', () => {
|
|
|
601
601
|
expect(vi.mocked(ctx.fillText)).not.toHaveBeenCalled()
|
|
602
602
|
})
|
|
603
603
|
|
|
604
|
-
it('retains custom KLineData fields in the
|
|
604
|
+
it('retains custom KLineData fields in the currentBar slot context', () => {
|
|
605
605
|
const onContext = vi.fn()
|
|
606
606
|
const plugin = createMainIndicatorLegendRendererPlugin({
|
|
607
607
|
yPaddingPx: 20,
|
|
@@ -619,7 +619,7 @@ describe('MainIndicatorLegend external mode & context callback', () => {
|
|
|
619
619
|
plugin.draw(context)
|
|
620
620
|
|
|
621
621
|
const legend = onContext.mock.calls[0]![0]
|
|
622
|
-
expect(legend.
|
|
623
|
-
expect(legend.
|
|
622
|
+
expect(legend.currentBar.turnoverRate).toBe(3.14)
|
|
623
|
+
expect(legend.currentBar.customLabel).toBe('featured')
|
|
624
624
|
})
|
|
625
625
|
})
|
|
@@ -8,6 +8,10 @@ import { RENDERER_PRIORITY } from '../../foundation/plugin/index'
|
|
|
8
8
|
import { resolveThemeColors } from '../../foundation/tokens/index'
|
|
9
9
|
import type { TimeShareData } from '../../foundation/types/price'
|
|
10
10
|
import { Indicator } from '../indicators/indicatorDefinitionRegistry'
|
|
11
|
+
import {
|
|
12
|
+
computeTimeSharePaneLayout,
|
|
13
|
+
resolveTimeShareBaseline,
|
|
14
|
+
} from '../modes/timeShareMath'
|
|
11
15
|
|
|
12
16
|
/** 成交量区域占 pane 高度的比例(底部) */
|
|
13
17
|
const VOLUME_RATIO = 0.25
|
|
@@ -34,15 +38,17 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost {
|
|
|
34
38
|
context.isAsiaMarket,
|
|
35
39
|
context.colorPresetSettings,
|
|
36
40
|
)
|
|
37
|
-
const preClose = (
|
|
38
|
-
|
|
41
|
+
const preClose = resolveTimeShareBaseline({
|
|
42
|
+
preClose: settings?.preClose as number | undefined,
|
|
43
|
+
firstPrice: tsData[0]?.price,
|
|
44
|
+
})
|
|
45
|
+
if (preClose === null) return
|
|
39
46
|
|
|
40
47
|
const paneHeight = pane.height
|
|
41
|
-
const
|
|
42
|
-
const priceAreaHeight =
|
|
48
|
+
const layout = computeTimeSharePaneLayout(paneHeight, VOLUME_RATIO)
|
|
49
|
+
const { volumeAreaHeight, priceAreaHeight } = layout
|
|
43
50
|
|
|
44
51
|
const { start, end } = range
|
|
45
|
-
const visibleCount = Math.min(end - start, tsData.length - start)
|
|
46
52
|
const itemCount = Math.min(end, tsData.length) - start
|
|
47
53
|
|
|
48
54
|
const xPositions: number[] = []
|
|
@@ -51,16 +57,19 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost {
|
|
|
51
57
|
const volumes: number[] = []
|
|
52
58
|
let maxVolume = 0
|
|
53
59
|
|
|
60
|
+
// 价格 Y 映射到 price 子区域:pane.yAxis 仍按全高,再线性压到 priceAreaHeight
|
|
61
|
+
const scaleYToPriceArea = (y: number) => (y / Math.max(paneHeight, 1)) * priceAreaHeight
|
|
62
|
+
|
|
54
63
|
for (let i = start; i < start + itemCount; i++) {
|
|
55
64
|
const item = tsData[i]
|
|
56
65
|
if (!item) continue
|
|
57
66
|
const x = kLineCenters[i - start]
|
|
58
67
|
if (x === undefined) continue
|
|
59
68
|
xPositions.push(x)
|
|
60
|
-
yPrices.push(pane.yAxis.priceToY(item.price))
|
|
61
|
-
yAvgs.push(pane.yAxis.priceToY(item.average))
|
|
69
|
+
yPrices.push(scaleYToPriceArea(pane.yAxis.priceToY(item.price)))
|
|
70
|
+
yAvgs.push(scaleYToPriceArea(pane.yAxis.priceToY(item.average)))
|
|
62
71
|
volumes.push(item.volume ?? 0)
|
|
63
|
-
maxVolume = Math.max(maxVolume, item.volume)
|
|
72
|
+
maxVolume = Math.max(maxVolume, item.volume ?? 0)
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
if (xPositions.length < 2) return
|
|
@@ -68,7 +77,13 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost {
|
|
|
68
77
|
ctx.save()
|
|
69
78
|
ctx.translate(-scrollLeft, 0)
|
|
70
79
|
|
|
71
|
-
const preCloseY = pane.yAxis.priceToY(preClose)
|
|
80
|
+
const preCloseY = scaleYToPriceArea(pane.yAxis.priceToY(preClose))
|
|
81
|
+
|
|
82
|
+
// 价格区裁剪,避免线/填充画进成交量区(translate 后世界 scrollLeft 映射到 0)
|
|
83
|
+
ctx.save()
|
|
84
|
+
ctx.beginPath()
|
|
85
|
+
ctx.rect(scrollLeft, 0, context.paneWidth, priceAreaHeight)
|
|
86
|
+
ctx.clip()
|
|
72
87
|
|
|
73
88
|
drawPreCloseLine(ctx, xPositions, preCloseY, dpr, colors.timeSharePreClose)
|
|
74
89
|
|
|
@@ -85,6 +100,7 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost {
|
|
|
85
100
|
drawSegmentLine(ctx, xPositions, yPrices, dpr, colors.timeSharePriceLine, 2)
|
|
86
101
|
|
|
87
102
|
drawSegmentLine(ctx, xPositions, yAvgs, dpr, colors.timeShareAvgLine, 1.5)
|
|
103
|
+
ctx.restore()
|
|
88
104
|
|
|
89
105
|
drawVolumeBars(
|
|
90
106
|
ctx,
|
|
@@ -43,4 +43,13 @@ describe('settingsState', () => {
|
|
|
43
43
|
;(snap as { showGridLines?: boolean }).showGridLines = false
|
|
44
44
|
}).toThrow()
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
it('preserves extension keys like preClose through resolve/patch', () => {
|
|
48
|
+
expect(resolveSettings({ preClose: 12.34 }).preClose).toBe(12.34)
|
|
49
|
+
const s = createSettingsState({ preClose: 12.34 })
|
|
50
|
+
expect(s.readonly.settings.peek().preClose).toBe(12.34)
|
|
51
|
+
s.actions.patch({ showGridLines: false })
|
|
52
|
+
expect(s.readonly.settings.peek().preClose).toBe(12.34)
|
|
53
|
+
expect(s.readonly.settings.peek().showGridLines).toBe(false)
|
|
54
|
+
})
|
|
46
55
|
})
|
|
@@ -158,6 +158,11 @@ export type ChartSettings = {
|
|
|
158
158
|
* @param partial - 偏好的部分设置(通常是组件 prop 传入)
|
|
159
159
|
* @returns 合并后的 ChartSettings 对象
|
|
160
160
|
*/
|
|
161
|
+
const KNOWN_SETTING_KEYS = new Set<string>([
|
|
162
|
+
...DEFAULT_SETTINGS.map((item) => item.key),
|
|
163
|
+
'colorPresetSettings',
|
|
164
|
+
])
|
|
165
|
+
|
|
161
166
|
export function resolveSettings(partial?: Partial<ChartSettings>): ChartSettings {
|
|
162
167
|
// 用 Partial<_SettingByKey> 而非 ChartSettings 避免交叉类型索引赋值报错
|
|
163
168
|
const result: Partial<_SettingByKey> = {}
|
|
@@ -170,6 +175,14 @@ export function resolveSettings(partial?: Partial<ChartSettings>): ChartSettings
|
|
|
170
175
|
;(result as ChartSettings).colorPresetSettings = normalizeColorPresetSettings(
|
|
171
176
|
partial?.colorPresetSettings,
|
|
172
177
|
)
|
|
178
|
+
// 保留扩展字段(如 preClose),避免业务元数据被 resolve 清掉
|
|
179
|
+
if (partial) {
|
|
180
|
+
for (const [key, value] of Object.entries(partial)) {
|
|
181
|
+
if (KNOWN_SETTING_KEYS.has(key)) continue
|
|
182
|
+
if (value === undefined) continue
|
|
183
|
+
;(result as Record<string, unknown>)[key] = value
|
|
184
|
+
}
|
|
185
|
+
}
|
|
173
186
|
return result as ChartSettings
|
|
174
187
|
}
|
|
175
188
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ASHARE_MARKET_SESSION,
|
|
5
|
+
HK_MARKET_SESSION,
|
|
6
|
+
US_MARKET_SESSION,
|
|
7
|
+
computeSessionTimeLabels,
|
|
8
|
+
countSessionSlots,
|
|
9
|
+
minuteOfDayToTimestamp,
|
|
10
|
+
resolveMarketSessionSlots,
|
|
11
|
+
type MarketSessionConfig,
|
|
12
|
+
type OpenTimeRange,
|
|
13
|
+
} from '../sessionTimeLabels'
|
|
14
|
+
|
|
15
|
+
function hm(h: number, m: number): number {
|
|
16
|
+
return h * 60 + m
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('countSessionSlots / resolveMarketSessionSlots', () => {
|
|
20
|
+
it('A-share is 240 one-minute slots', () => {
|
|
21
|
+
expect(countSessionSlots(ASHARE_MARKET_SESSION.sessions)).toBe(240)
|
|
22
|
+
expect(resolveMarketSessionSlots(ASHARE_MARKET_SESSION)).toBe(240)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('HK is 330, US is 390 (not maxed to 240)', () => {
|
|
26
|
+
expect(resolveMarketSessionSlots(HK_MARKET_SESSION)).toBe(330)
|
|
27
|
+
expect(resolveMarketSessionSlots(US_MARKET_SESSION)).toBe(390)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('respects slotMinutes (e.g. 5-min bars)', () => {
|
|
31
|
+
const cfg: MarketSessionConfig = {
|
|
32
|
+
...ASHARE_MARKET_SESSION,
|
|
33
|
+
slotMinutes: 5,
|
|
34
|
+
}
|
|
35
|
+
// 240 分钟 / 5 = 48
|
|
36
|
+
expect(resolveMarketSessionSlots(cfg)).toBe(48)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not expand slots by arrived data length (sessions are SSOT)', () => {
|
|
40
|
+
expect(resolveMarketSessionSlots(ASHARE_MARKET_SESSION, 300)).toBe(240)
|
|
41
|
+
expect(resolveMarketSessionSlots(HK_MARKET_SESSION, 100)).toBe(330)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('computeSessionTimeLabels multi-market', () => {
|
|
46
|
+
it('A-share closed-side endpoints only', () => {
|
|
47
|
+
const labels = computeSessionTimeLabels(ASHARE_MARKET_SESSION.sessions, { axisWidth: 800 })
|
|
48
|
+
expect(labels.map((l) => l.minuteOfDay)).toEqual([hm(9, 30), hm(13, 0), hm(15, 0)])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('HK endpoints: 09:30 / 13:00 / 16:00', () => {
|
|
52
|
+
const labels = computeSessionTimeLabels(HK_MARKET_SESSION.sessions, { axisWidth: 800 })
|
|
53
|
+
expect(labels.map((l) => l.minuteOfDay)).toEqual([hm(9, 30), hm(13, 0), hm(16, 0)])
|
|
54
|
+
expect(labels.map((l) => l.slotIndex)).toEqual([0, 150, 329])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('US single continuous session endpoints: 09:30 / 16:00', () => {
|
|
58
|
+
const labels = computeSessionTimeLabels(US_MARKET_SESSION.sessions, { axisWidth: 800 })
|
|
59
|
+
expect(labels.map((l) => l.minuteOfDay)).toEqual([hm(9, 30), hm(16, 0)])
|
|
60
|
+
expect(labels.map((l) => l.slotIndex)).toEqual([0, 389])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('accepts arbitrary sessions via config.sessions', () => {
|
|
64
|
+
const night: OpenTimeRange[] = [
|
|
65
|
+
{ open: hm(21, 0), close: hm(23, 0) },
|
|
66
|
+
{ open: hm(0, 0), close: hm(2, 30) },
|
|
67
|
+
]
|
|
68
|
+
// 两段同日分钟:21:00-23:00 (120) + 0:00-2:30 (150) = 270
|
|
69
|
+
// 闭侧端点:21:00, 0:00, 2:30
|
|
70
|
+
const labels = computeSessionTimeLabels(night, { axisWidth: 800 })
|
|
71
|
+
expect(labels.map((l) => l.minuteOfDay)).toEqual([hm(21, 0), hm(0, 0), hm(2, 30)])
|
|
72
|
+
expect(countSessionSlots(night)).toBe(270)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('minuteOfDayToTimestamp timeZone', () => {
|
|
77
|
+
it('maps wall clock in Asia/Shanghai independently of host local TZ', () => {
|
|
78
|
+
// 任意 UTC 锚点落在 2026-07-21 上海日历日
|
|
79
|
+
const base = Date.UTC(2026, 6, 21, 4, 0, 0) // 12:00 CST
|
|
80
|
+
const ts = minuteOfDayToTimestamp(base, hm(9, 30), 'Asia/Shanghai')
|
|
81
|
+
// 2026-07-21 09:30 Asia/Shanghai = 01:30 UTC
|
|
82
|
+
expect(ts).toBe(Date.UTC(2026, 6, 21, 1, 30, 0))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('maps wall clock in America/New_York for US session open', () => {
|
|
86
|
+
// 2026-07-21 是美东夏令时 EDT (UTC-4)
|
|
87
|
+
const base = Date.UTC(2026, 6, 21, 16, 0, 0) // 12:00 EDT
|
|
88
|
+
const ts = minuteOfDayToTimestamp(base, hm(9, 30), 'America/New_York')
|
|
89
|
+
expect(ts).toBe(Date.UTC(2026, 6, 21, 13, 30, 0)) // 09:30 EDT
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -12,6 +12,14 @@ import {
|
|
|
12
12
|
findDayBoundaries,
|
|
13
13
|
} from '../dateFormat'
|
|
14
14
|
import { priceToY, yToPrice } from '../priceToY'
|
|
15
|
+
import {
|
|
16
|
+
ASHARE_MARKET_SESSION,
|
|
17
|
+
computeTimeShareTimeLabels,
|
|
18
|
+
minuteOfDayToTimestamp,
|
|
19
|
+
resolveMarketSessionSlots,
|
|
20
|
+
timeShareSlotCenterX,
|
|
21
|
+
type MarketSessionConfig,
|
|
22
|
+
} from '../timeShareAxisLabels'
|
|
15
23
|
|
|
16
24
|
const textWidthCache = new Map<string, number>()
|
|
17
25
|
const TEXT_WIDTH_CACHE_LIMIT = 512
|
|
@@ -63,6 +71,8 @@ export interface TimeAxisOptions {
|
|
|
63
71
|
monthKeys?: Int32Array
|
|
64
72
|
/** 预计算的日期键值数组(year*366+dayOfYear),与 data 长度一致 */
|
|
65
73
|
dayKeys?: Int32Array
|
|
74
|
+
/** 分时市场 session;默认 A 股 */
|
|
75
|
+
marketSession?: MarketSessionConfig
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
export interface LastPriceLineOptions {
|
|
@@ -362,16 +372,47 @@ export function drawTimeAxis(
|
|
|
362
372
|
let labelFn: (ts: number) => { text: string; isYear: boolean }
|
|
363
373
|
|
|
364
374
|
if (isTimeShare) {
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
375
|
+
// 只画各区间闭侧端点;全天首/末槽贴边 left/right 对齐
|
|
376
|
+
const market = opts.marketSession ?? ASHARE_MARKET_SESSION
|
|
377
|
+
const sessionSlots = resolveMarketSessionSlots(market)
|
|
378
|
+
const labels = computeTimeShareTimeLabels({
|
|
379
|
+
axisWidth: width,
|
|
380
|
+
marketSession: market,
|
|
381
|
+
minLabelSpacingPx: 56,
|
|
382
|
+
})
|
|
383
|
+
const baseTs = data[0]?.timestamp ?? Date.now()
|
|
384
|
+
setCanvasFont(ctx, regularFont)
|
|
385
|
+
ctx.textBaseline = 'middle'
|
|
386
|
+
const edgePad = 2
|
|
387
|
+
const lastSlot = Math.max(0, sessionSlots - 1)
|
|
388
|
+
for (const label of labels) {
|
|
389
|
+
const ts = minuteOfDayToTimestamp(baseTs, label.minuteOfDay, market.timeZone)
|
|
390
|
+
const text = formatTimeLabel(ts)
|
|
391
|
+
const isDayStart = label.slotIndex === 0
|
|
392
|
+
const isDayEnd = label.slotIndex === lastSlot
|
|
393
|
+
|
|
394
|
+
let align: CanvasTextAlign = 'center'
|
|
395
|
+
let drawX: number
|
|
396
|
+
if (isDayStart) {
|
|
397
|
+
align = 'left'
|
|
398
|
+
drawX = x + edgePad
|
|
399
|
+
} else if (isDayEnd) {
|
|
400
|
+
align = 'right'
|
|
401
|
+
drawX = x + width - edgePad
|
|
402
|
+
} else {
|
|
403
|
+
const centerX = timeShareSlotCenterX(label.slotIndex, width, sessionSlots, dpr)
|
|
404
|
+
drawX = centerX - scrollLeft
|
|
405
|
+
if (drawX < edgePad || drawX > width - edgePad) continue
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
ctx.textAlign = align
|
|
409
|
+
ctx.fillText(text, roundToPhysicalPixel(drawX, dpr), alignToPhysicalPixelCenter(textY, dpr))
|
|
372
410
|
}
|
|
373
|
-
|
|
374
|
-
|
|
411
|
+
ctx.textAlign = 'center'
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (isMinuteData) {
|
|
375
416
|
boundaries = findDayBoundaries(data, opts.dayKeys)
|
|
376
417
|
labelFn = formatDay
|
|
377
418
|
} else {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开盘时段:minuteOfDay 为自 0 点起的分钟数(交易所本地墙钟)。
|
|
3
|
+
* sessions[0..n-2] 左闭右开 [open, close);sessions[n-1] 左闭右闭 [open, close]。
|
|
4
|
+
* 只打印闭侧端点:半开段不展示 close。
|
|
5
|
+
*/
|
|
6
|
+
export type OpenTimeRange = {
|
|
7
|
+
open: number
|
|
8
|
+
close: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 市场分时 session 配置(几何 / 标签共用) */
|
|
12
|
+
export type MarketSessionConfig = {
|
|
13
|
+
/** IANA 时区,如 Asia/Shanghai */
|
|
14
|
+
timeZone: string
|
|
15
|
+
sessions: ReadonlyArray<OpenTimeRange>
|
|
16
|
+
/** 每个 bar 槽对应的分钟数,默认 1 */
|
|
17
|
+
slotMinutes?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SessionTimeLabel = {
|
|
21
|
+
/** 自 0 点起的分钟数(交易所墙钟) */
|
|
22
|
+
minuteOfDay: number
|
|
23
|
+
/** 在展开后的槽序列中的索引(0-based) */
|
|
24
|
+
slotIndex: number
|
|
25
|
+
/** 是否为闭侧端点 */
|
|
26
|
+
isEndpoint: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SessionTimeLabelOptions = {
|
|
30
|
+
axisWidth: number
|
|
31
|
+
/** 保留字段,端点-only 模式下不参与密度计算 */
|
|
32
|
+
minLabelSpacingPx?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hm(h: number, m: number): number {
|
|
36
|
+
return h * 60 + m
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A 股默认 */
|
|
40
|
+
export const ASHARE_OPEN_SESSIONS: readonly OpenTimeRange[] = [
|
|
41
|
+
{ open: hm(9, 30), close: hm(11, 30) },
|
|
42
|
+
{ open: hm(13, 0), close: hm(15, 0) },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export const ASHARE_MARKET_SESSION: MarketSessionConfig = {
|
|
46
|
+
timeZone: 'Asia/Shanghai',
|
|
47
|
+
sessions: ASHARE_OPEN_SESSIONS,
|
|
48
|
+
slotMinutes: 1,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 港股 */
|
|
52
|
+
export const HK_MARKET_SESSION: MarketSessionConfig = {
|
|
53
|
+
timeZone: 'Asia/Hong_Kong',
|
|
54
|
+
sessions: [
|
|
55
|
+
{ open: hm(9, 30), close: hm(12, 0) },
|
|
56
|
+
{ open: hm(13, 0), close: hm(16, 0) },
|
|
57
|
+
],
|
|
58
|
+
slotMinutes: 1,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 美股(常规盘,无午休) */
|
|
62
|
+
export const US_MARKET_SESSION: MarketSessionConfig = {
|
|
63
|
+
timeZone: 'America/New_York',
|
|
64
|
+
sessions: [{ open: hm(9, 30), close: hm(16, 0) }],
|
|
65
|
+
slotMinutes: 1,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 只输出各区间闭侧端点。
|
|
70
|
+
* - 非末段 [open, close):仅 open
|
|
71
|
+
* - 末段 [open, close]:open 与 close
|
|
72
|
+
* close 标签映射到 close-1 槽(bar 序列为各段 [open, close))。
|
|
73
|
+
*/
|
|
74
|
+
export function computeSessionTimeLabels(
|
|
75
|
+
sessions: ReadonlyArray<OpenTimeRange>,
|
|
76
|
+
options: SessionTimeLabelOptions,
|
|
77
|
+
): SessionTimeLabel[] {
|
|
78
|
+
if (!(options.axisWidth > 0) || sessions.length === 0) return []
|
|
79
|
+
|
|
80
|
+
const labels: SessionTimeLabel[] = []
|
|
81
|
+
let slotOffset = 0
|
|
82
|
+
|
|
83
|
+
for (let s = 0; s < sessions.length; s++) {
|
|
84
|
+
const range = sessions[s]!
|
|
85
|
+
if (!(range.close > range.open)) continue
|
|
86
|
+
const slotCount = range.close - range.open
|
|
87
|
+
const isLast = s === sessions.length - 1
|
|
88
|
+
|
|
89
|
+
labels.push({
|
|
90
|
+
minuteOfDay: range.open,
|
|
91
|
+
slotIndex: slotOffset,
|
|
92
|
+
isEndpoint: true,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
if (isLast) {
|
|
96
|
+
labels.push({
|
|
97
|
+
minuteOfDay: range.close,
|
|
98
|
+
slotIndex: slotOffset + slotCount - 1,
|
|
99
|
+
isEndpoint: true,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
slotOffset += slotCount
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const bySlot = new Map<number, SessionTimeLabel>()
|
|
107
|
+
for (const label of labels) {
|
|
108
|
+
bySlot.set(label.slotIndex, label)
|
|
109
|
+
}
|
|
110
|
+
return [...bySlot.values()].sort((a, b) => a.slotIndex - b.slotIndex)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 槽总数(各段 [open, close) 分钟数之和;未除 slotMinutes) */
|
|
114
|
+
export function countSessionSlots(sessions: ReadonlyArray<OpenTimeRange>): number {
|
|
115
|
+
let n = 0
|
|
116
|
+
for (const r of sessions) {
|
|
117
|
+
if (r.close > r.open) n += r.close - r.open
|
|
118
|
+
}
|
|
119
|
+
return n
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 全天 bar 槽数 = 分钟总和 / slotMinutes。
|
|
124
|
+
* sessions 为 SSOT,不因 arrivedCount 放大。
|
|
125
|
+
*/
|
|
126
|
+
export function resolveMarketSessionSlots(
|
|
127
|
+
config: MarketSessionConfig,
|
|
128
|
+
_arrivedCount?: number,
|
|
129
|
+
): number {
|
|
130
|
+
const minutes = countSessionSlots(config.sessions)
|
|
131
|
+
const step = config.slotMinutes && config.slotMinutes > 0 ? config.slotMinutes : 1
|
|
132
|
+
return Math.max(0, Math.floor(minutes / step))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 将交易所墙钟 minuteOfDay 转为 UTC 毫秒。
|
|
137
|
+
* baseTimestamp 用于确定「哪一天」(按 timeZone 日历日)。
|
|
138
|
+
*/
|
|
139
|
+
export function minuteOfDayToTimestamp(
|
|
140
|
+
baseTimestamp: number,
|
|
141
|
+
minuteOfDay: number,
|
|
142
|
+
timeZone: string = 'Asia/Shanghai',
|
|
143
|
+
): number {
|
|
144
|
+
const { year, month, day } = getCalendarPartsInTimeZone(baseTimestamp, timeZone)
|
|
145
|
+
return zonedWallTimeToUtc(year, month, day, minuteOfDay, timeZone)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 槽中心 X */
|
|
149
|
+
export function sessionSlotCenterX(
|
|
150
|
+
slotIndex: number,
|
|
151
|
+
axisWidth: number,
|
|
152
|
+
sessionSlots: number,
|
|
153
|
+
dpr: number,
|
|
154
|
+
): number {
|
|
155
|
+
if (!(axisWidth > 0) || !(sessionSlots > 0)) return 0
|
|
156
|
+
const step = axisWidth / sessionSlots
|
|
157
|
+
return Math.round((slotIndex + 0.5) * step * dpr) / dpr
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── 时区工具(无第三方依赖,Intl) ──
|
|
161
|
+
|
|
162
|
+
function getCalendarPartsInTimeZone(
|
|
163
|
+
timestamp: number,
|
|
164
|
+
timeZone: string,
|
|
165
|
+
): { year: number; month: number; day: number } {
|
|
166
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
167
|
+
timeZone,
|
|
168
|
+
year: 'numeric',
|
|
169
|
+
month: '2-digit',
|
|
170
|
+
day: '2-digit',
|
|
171
|
+
}).formatToParts(new Date(timestamp))
|
|
172
|
+
let year = 0
|
|
173
|
+
let month = 1
|
|
174
|
+
let day = 1
|
|
175
|
+
for (const p of parts) {
|
|
176
|
+
if (p.type === 'year') year = Number(p.value)
|
|
177
|
+
else if (p.type === 'month') month = Number(p.value)
|
|
178
|
+
else if (p.type === 'day') day = Number(p.value)
|
|
179
|
+
}
|
|
180
|
+
return { year, month, day }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 将某时区墙钟 (y-m-d + minuteOfDay) 转为 UTC epoch。
|
|
185
|
+
* 用迭代修正 UTC 猜测,覆盖 DST。
|
|
186
|
+
*/
|
|
187
|
+
function zonedWallTimeToUtc(
|
|
188
|
+
year: number,
|
|
189
|
+
month: number,
|
|
190
|
+
day: number,
|
|
191
|
+
minuteOfDay: number,
|
|
192
|
+
timeZone: string,
|
|
193
|
+
): number {
|
|
194
|
+
const hour = Math.floor(minuteOfDay / 60)
|
|
195
|
+
const minute = minuteOfDay % 60
|
|
196
|
+
// 初值:当作 UTC 墙钟
|
|
197
|
+
let utc = Date.UTC(year, month - 1, day, hour, minute, 0, 0)
|
|
198
|
+
for (let i = 0; i < 4; i++) {
|
|
199
|
+
const wall = getWallClockInTimeZone(utc, timeZone)
|
|
200
|
+
const desiredMin = hour * 60 + minute
|
|
201
|
+
const actualMin = wall.hour * 60 + wall.minute
|
|
202
|
+
const dayDelta =
|
|
203
|
+
Date.UTC(year, month - 1, day) - Date.UTC(wall.year, wall.month - 1, wall.day)
|
|
204
|
+
const minDelta = desiredMin - actualMin + dayDelta / 60_000
|
|
205
|
+
if (minDelta === 0) break
|
|
206
|
+
utc += minDelta * 60_000
|
|
207
|
+
}
|
|
208
|
+
return utc
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getWallClockInTimeZone(
|
|
212
|
+
timestamp: number,
|
|
213
|
+
timeZone: string,
|
|
214
|
+
): { year: number; month: number; day: number; hour: number; minute: number } {
|
|
215
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
216
|
+
timeZone,
|
|
217
|
+
year: 'numeric',
|
|
218
|
+
month: '2-digit',
|
|
219
|
+
day: '2-digit',
|
|
220
|
+
hour: '2-digit',
|
|
221
|
+
minute: '2-digit',
|
|
222
|
+
hourCycle: 'h23',
|
|
223
|
+
}).formatToParts(new Date(timestamp))
|
|
224
|
+
let year = 0
|
|
225
|
+
let month = 1
|
|
226
|
+
let day = 1
|
|
227
|
+
let hour = 0
|
|
228
|
+
let minute = 0
|
|
229
|
+
for (const p of parts) {
|
|
230
|
+
if (p.type === 'year') year = Number(p.value)
|
|
231
|
+
else if (p.type === 'month') month = Number(p.value)
|
|
232
|
+
else if (p.type === 'day') day = Number(p.value)
|
|
233
|
+
else if (p.type === 'hour') hour = Number(p.value)
|
|
234
|
+
else if (p.type === 'minute') minute = Number(p.value)
|
|
235
|
+
}
|
|
236
|
+
return { year, month, day, hour, minute }
|
|
237
|
+
}
|