@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
package/src/engine/chart.ts
CHANGED
|
@@ -151,7 +151,13 @@ export class Chart {
|
|
|
151
151
|
zoomLevel: number
|
|
152
152
|
scaleTypes: Map<string, ScaleType>
|
|
153
153
|
mainIndicators: Array<{ id: string; params: Record<string, number | boolean | string> }>
|
|
154
|
-
subPanes: Array<{
|
|
154
|
+
subPanes: Array<{
|
|
155
|
+
paneId: string
|
|
156
|
+
indicatorId: string
|
|
157
|
+
params: Record<string, unknown>
|
|
158
|
+
}>
|
|
159
|
+
paneRatios: Record<string, number>
|
|
160
|
+
candleEnabled: boolean
|
|
155
161
|
} | null = null
|
|
156
162
|
|
|
157
163
|
/** 上次预警评估的最新 K 线时间戳(用于去重) */
|
|
@@ -470,17 +476,21 @@ export class Chart {
|
|
|
470
476
|
const prev = this._activeMode
|
|
471
477
|
|
|
472
478
|
if (mode === this._timeShareMode) {
|
|
479
|
+
const candlePlugin = this.rendererPluginManager.getPlugin('candle')
|
|
473
480
|
this._savedTimeShareState = {
|
|
474
481
|
zoomLevel: this.kernel.zoom.readonly.zoomLevel.peek(),
|
|
475
482
|
scaleTypes: new Map(this.kernel.pane.readonly.paneScaleTypes.peek()),
|
|
476
483
|
mainIndicators: [],
|
|
477
484
|
subPanes: [],
|
|
485
|
+
paneRatios: { ...this.kernel.pane.readonly.paneRatios.peek() },
|
|
486
|
+
candleEnabled: candlePlugin?.enabled !== false,
|
|
478
487
|
}
|
|
479
488
|
for (const [id, entry] of this.kernel.indicator.readonly.mainIndicators.peek()) {
|
|
480
489
|
this._savedTimeShareState.mainIndicators.push({ id, params: { ...entry.params } })
|
|
481
490
|
}
|
|
482
491
|
this._savedTimeShareState.subPanes = this.indicatorManager.getSubPaneEntries().map((e) => ({
|
|
483
|
-
|
|
492
|
+
paneId: e.paneId,
|
|
493
|
+
indicatorId: e.indicatorId,
|
|
484
494
|
params: { ...e.params },
|
|
485
495
|
}))
|
|
486
496
|
// 分时强制 percent 进 kernel,再投影(不再由 updatePaneRange 每帧旁路写)
|
|
@@ -526,7 +536,7 @@ export class Chart {
|
|
|
526
536
|
|
|
527
537
|
if (mode === this._timeShareMode) {
|
|
528
538
|
for (const { id } of this._savedTimeShareState!.mainIndicators) {
|
|
529
|
-
if (id !== 'TIMESHARE') {
|
|
539
|
+
if (id !== 'TIMESHARE' && id !== 'timeShare') {
|
|
530
540
|
this.indicatorManager.disableMainIndicator(id)
|
|
531
541
|
}
|
|
532
542
|
}
|
|
@@ -535,13 +545,26 @@ export class Chart {
|
|
|
535
545
|
const saved = this._savedTimeShareState
|
|
536
546
|
if (saved) {
|
|
537
547
|
for (const { id, params } of saved.mainIndicators) {
|
|
538
|
-
if (id !== 'TIMESHARE') {
|
|
548
|
+
if (id !== 'TIMESHARE' && id !== 'timeShare') {
|
|
539
549
|
this.indicatorManager.enableMainIndicator(id, params)
|
|
540
550
|
}
|
|
541
551
|
}
|
|
542
|
-
for (const {
|
|
543
|
-
this.indicatorManager.
|
|
552
|
+
for (const { paneId, indicatorId, params } of saved.subPanes) {
|
|
553
|
+
this.indicatorManager.createSubPane(
|
|
554
|
+
paneId,
|
|
555
|
+
indicatorId as SubIndicatorType,
|
|
556
|
+
params as Record<string, number | boolean | string>,
|
|
557
|
+
)
|
|
544
558
|
}
|
|
559
|
+
// 恢复进入分时前的 pane 比例(createSubPane 会重算 3:1 分配)
|
|
560
|
+
if (Object.keys(saved.paneRatios).length > 0) {
|
|
561
|
+
const specs = this.kernel.pane.readonly.paneSpecs.peek().map((pane) => ({
|
|
562
|
+
...pane,
|
|
563
|
+
ratio: saved.paneRatios[pane.id] ?? pane.ratio,
|
|
564
|
+
}))
|
|
565
|
+
this.kernel.pane.actions.commitLayout(saved.paneRatios, specs)
|
|
566
|
+
}
|
|
567
|
+
this.setRendererEnabled('candle', saved.candleEnabled)
|
|
545
568
|
}
|
|
546
569
|
this._savedTimeShareState = null
|
|
547
570
|
}
|
|
@@ -452,6 +452,16 @@ export class ChartDataManager {
|
|
|
452
452
|
return buf ? buf.getRawData() : []
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
getTimeSharePreClose(): number | null {
|
|
456
|
+
const buf = this.getActiveTimeShareBuffer()
|
|
457
|
+
return buf?.getPreClose() ?? null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
setTimeSharePreClose(preClose: number | null): void {
|
|
461
|
+
const buf = this.getActiveTimeShareBuffer()
|
|
462
|
+
if (buf) buf.setPreClose(preClose)
|
|
463
|
+
}
|
|
464
|
+
|
|
455
465
|
getTimeShareSignal(): ReadonlySignal<ReadonlyArray<TimeShareData>> {
|
|
456
466
|
const buf = this.getActiveTimeShareBuffer()
|
|
457
467
|
return (buf?.data ?? createSignal<ReadonlyArray<TimeShareData>>([])) as ReadonlySignal<
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveTimeShareSlotTimestamp,
|
|
5
|
+
timeShareSlotCenterX,
|
|
6
|
+
} from '../../../foundation/utils/timeShareAxisLabels'
|
|
7
|
+
import {
|
|
8
|
+
ASHARE_TIMESHARE_SESSION_SLOTS,
|
|
9
|
+
computeTimeShareBarMetrics,
|
|
10
|
+
computeTimeSharePaneLayout,
|
|
11
|
+
computeTimeSharePriceRange,
|
|
12
|
+
computeTimeShareTimeLabelIndices,
|
|
13
|
+
computeTimeShareXLayout,
|
|
14
|
+
resolveTimeShareSessionSlots,
|
|
15
|
+
resolveTimeShareBaseline,
|
|
16
|
+
} from '../timeShareMath'
|
|
17
|
+
|
|
18
|
+
describe('resolveTimeShareBaseline', () => {
|
|
19
|
+
it('prefers finite non-zero preClose over first trade price', () => {
|
|
20
|
+
expect(resolveTimeShareBaseline({ preClose: 10.5, firstPrice: 11 })).toBe(10.5)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('falls back to first price when preClose is missing or zero', () => {
|
|
24
|
+
expect(resolveTimeShareBaseline({ preClose: 0, firstPrice: 11 })).toBe(11)
|
|
25
|
+
expect(resolveTimeShareBaseline({ firstPrice: 11 })).toBe(11)
|
|
26
|
+
expect(resolveTimeShareBaseline({ preClose: null, firstPrice: 9 })).toBe(9)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns null when no valid baseline exists', () => {
|
|
30
|
+
expect(resolveTimeShareBaseline({ preClose: 0, firstPrice: 0 })).toBeNull()
|
|
31
|
+
expect(resolveTimeShareBaseline({})).toBeNull()
|
|
32
|
+
expect(resolveTimeShareBaseline({ preClose: NaN, firstPrice: Infinity })).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('computeTimeSharePriceRange', () => {
|
|
37
|
+
it('uses preClose baseline and pads around max absolute percent move', () => {
|
|
38
|
+
// open gap up: first trade 11, preClose 10 → +10%; later 10.5 → 5%
|
|
39
|
+
const range = computeTimeSharePriceRange([11, 10.5, 10.2], 10)
|
|
40
|
+
expect(range).not.toBeNull()
|
|
41
|
+
// maxAbsPct=10, padding=max(1, 0.5)=1 → display 11%
|
|
42
|
+
expect(range!.maxPrice).toBeCloseTo(10 * 1.11, 8)
|
|
43
|
+
expect(range!.minPrice).toBeCloseTo(10 * 0.89, 8)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('still applies minimum padding when all prices equal baseline (flat day)', () => {
|
|
47
|
+
const range = computeTimeSharePriceRange([10, 10, 10], 10)
|
|
48
|
+
expect(range).not.toBeNull()
|
|
49
|
+
// maxAbsPct=0, padding=min 0.5% → ±0.5%
|
|
50
|
+
expect(range!.maxPrice).toBeCloseTo(10.05, 8)
|
|
51
|
+
expect(range!.minPrice).toBeCloseTo(9.95, 8)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns null for invalid baseline', () => {
|
|
55
|
+
expect(computeTimeSharePriceRange([10], 0)).toBeNull()
|
|
56
|
+
expect(computeTimeSharePriceRange([10], NaN)).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('resolveTimeShareSessionSlots', () => {
|
|
61
|
+
it('defaults to A-share 240 one-minute slots (sessions SSOT)', () => {
|
|
62
|
+
expect(ASHARE_TIMESHARE_SESSION_SLOTS).toBe(240)
|
|
63
|
+
expect(resolveTimeShareSessionSlots(0)).toBe(240)
|
|
64
|
+
expect(resolveTimeShareSessionSlots(60)).toBe(240)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('does not expand when arrived points exceed session slots', () => {
|
|
68
|
+
expect(resolveTimeShareSessionSlots(300)).toBe(240)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('computeTimeShareBarMetrics', () => {
|
|
73
|
+
it('divides viewWidth by full session slots, not arrived point count', () => {
|
|
74
|
+
// 盘中仅 60 点:宽度仍按 240 槽划分,不把 60 点拉满屏
|
|
75
|
+
const partial = computeTimeShareBarMetrics(60, 320, 1)
|
|
76
|
+
const full = computeTimeShareBarMetrics(240, 320, 1)
|
|
77
|
+
expect(partial).not.toBeNull()
|
|
78
|
+
expect(full).not.toBeNull()
|
|
79
|
+
expect(partial!.kWidth).toBeCloseTo(full!.kWidth, 8)
|
|
80
|
+
expect(partial!.kGap).toBeCloseTo(full!.kGap, 8)
|
|
81
|
+
expect((partial!.kWidth + partial!.kGap) * 240).toBeCloseTo(320, 6)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('fits full session into viewWidth even when per-bar width is sub-pixel (DPR=1 narrow)', () => {
|
|
85
|
+
const m = computeTimeShareBarMetrics(240, 320, 1)
|
|
86
|
+
expect(m).not.toBeNull()
|
|
87
|
+
const unit = m!.kWidth + m!.kGap
|
|
88
|
+
expect(unit * 240).toBeCloseTo(320, 6)
|
|
89
|
+
expect(m!.kWidth).toBeGreaterThan(0)
|
|
90
|
+
expect(m!.kGap).toBeGreaterThanOrEqual(0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('keeps full session visible at high DPR', () => {
|
|
94
|
+
const m = computeTimeShareBarMetrics(240, 320, 2)
|
|
95
|
+
expect(m).not.toBeNull()
|
|
96
|
+
expect((m!.kWidth + m!.kGap) * 240).toBeCloseTo(320, 6)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns null for empty data or invalid width', () => {
|
|
100
|
+
expect(computeTimeShareBarMetrics(0, 320, 1)).toBeNull()
|
|
101
|
+
expect(computeTimeShareBarMetrics(10, 0, 1)).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('computeTimeShareXLayout', () => {
|
|
106
|
+
it('places partial-day points on session timeline leaving right-side blank', () => {
|
|
107
|
+
const layout = computeTimeShareXLayout({
|
|
108
|
+
arrivedCount: 60,
|
|
109
|
+
sessionSlots: 240,
|
|
110
|
+
totalWidth: 480,
|
|
111
|
+
dpr: 1,
|
|
112
|
+
})
|
|
113
|
+
expect(layout).not.toBeNull()
|
|
114
|
+
// step = 480/240 = 2;第 0 点中心 1,第 59 点中心 119,未到右缘 480
|
|
115
|
+
expect(layout!.centers[0]).toBeCloseTo(1, 6)
|
|
116
|
+
expect(layout!.centers[59]).toBeCloseTo(119, 6)
|
|
117
|
+
expect(layout!.centers[59]!).toBeLessThan(480 * 0.3)
|
|
118
|
+
expect(layout!.step).toBeCloseTo(2, 6)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('fills full width only when arrivedCount covers full session', () => {
|
|
122
|
+
const layout = computeTimeShareXLayout({
|
|
123
|
+
arrivedCount: 240,
|
|
124
|
+
sessionSlots: 240,
|
|
125
|
+
totalWidth: 480,
|
|
126
|
+
dpr: 1,
|
|
127
|
+
})
|
|
128
|
+
expect(layout).not.toBeNull()
|
|
129
|
+
expect(layout!.centers[0]).toBeCloseTo(1, 6)
|
|
130
|
+
expect(layout!.centers[239]).toBeCloseTo(479, 6)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('computeTimeSharePaneLayout', () => {
|
|
135
|
+
it('splits price area above volume area without overlap', () => {
|
|
136
|
+
const layout = computeTimeSharePaneLayout(400, 0.25)
|
|
137
|
+
expect(layout.priceAreaHeight).toBe(300)
|
|
138
|
+
expect(layout.volumeAreaHeight).toBe(100)
|
|
139
|
+
expect(layout.priceTop).toBe(0)
|
|
140
|
+
expect(layout.volumeTop).toBe(300)
|
|
141
|
+
expect(layout.priceTop + layout.priceAreaHeight).toBe(layout.volumeTop)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('computeTimeShareTimeLabelIndices', () => {
|
|
146
|
+
it('only session closed-side endpoints: 9:30 / 13:00 / 15:00', () => {
|
|
147
|
+
const labels = computeTimeShareTimeLabelIndices({
|
|
148
|
+
axisWidth: 800,
|
|
149
|
+
sessionSlots: 240,
|
|
150
|
+
})
|
|
151
|
+
// 9:30 → 0;13:00 → 120;15:00 → 239
|
|
152
|
+
expect(labels).toEqual([0, 120, 239])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('returns empty for invalid axis width', () => {
|
|
156
|
+
expect(
|
|
157
|
+
computeTimeShareTimeLabelIndices({
|
|
158
|
+
axisWidth: 0,
|
|
159
|
+
sessionSlots: 240,
|
|
160
|
+
}),
|
|
161
|
+
).toEqual([])
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('timeShare slot time/x helpers', () => {
|
|
166
|
+
it('maps A-share slots across lunch break', () => {
|
|
167
|
+
// 2026-07-21 local
|
|
168
|
+
const day = new Date(2026, 6, 21, 10, 0, 0, 0).getTime()
|
|
169
|
+
expect(new Date(resolveTimeShareSlotTimestamp(day, 0)).getHours()).toBe(9)
|
|
170
|
+
expect(new Date(resolveTimeShareSlotTimestamp(day, 0)).getMinutes()).toBe(30)
|
|
171
|
+
// slot 120 = 13:00
|
|
172
|
+
expect(new Date(resolveTimeShareSlotTimestamp(day, 120)).getHours()).toBe(13)
|
|
173
|
+
expect(new Date(resolveTimeShareSlotTimestamp(day, 120)).getMinutes()).toBe(0)
|
|
174
|
+
// slot 239 = 14:59
|
|
175
|
+
expect(new Date(resolveTimeShareSlotTimestamp(day, 239)).getHours()).toBe(14)
|
|
176
|
+
expect(new Date(resolveTimeShareSlotTimestamp(day, 239)).getMinutes()).toBe(59)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('places last session slot near right edge of axis', () => {
|
|
180
|
+
const x0 = timeShareSlotCenterX(0, 480, 240, 1)
|
|
181
|
+
const xLast = timeShareSlotCenterX(239, 480, 240, 1)
|
|
182
|
+
expect(x0).toBeCloseTo(1, 6)
|
|
183
|
+
expect(xLast).toBeCloseTo(479, 6)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { Pane } from '../../layout/pane'
|
|
4
|
+
import { TimeShareMode } from '../timeShareMode'
|
|
5
|
+
import type { TimeShareData } from '../../../foundation/types/price'
|
|
6
|
+
|
|
7
|
+
function ts(price: number, i = 0): TimeShareData {
|
|
8
|
+
return { timestamp: i, price, average: price, volume: 1, amount: price }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function mockDm(points: TimeShareData[], preClose: number | null = null) {
|
|
12
|
+
return {
|
|
13
|
+
getTimeShareData: () => points,
|
|
14
|
+
getTimeSharePreClose: () => preClose,
|
|
15
|
+
} as unknown as import('../../data/chartDataManager').ChartDataManager
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('TimeShareMode', () => {
|
|
19
|
+
it('computeKWidth fits full session into narrow view (no DPR truncation)', () => {
|
|
20
|
+
const mode = new TimeShareMode()
|
|
21
|
+
const m = mode.computeKWidth(240, 320, 1)
|
|
22
|
+
expect(m).not.toBeNull()
|
|
23
|
+
expect((m!.kWidth + m!.kGap) * 240).toBeCloseTo(320, 6)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('computeKWidth for partial day matches full-session bar size', () => {
|
|
27
|
+
const mode = new TimeShareMode()
|
|
28
|
+
const partial = mode.computeKWidth(60, 320, 1)
|
|
29
|
+
const full = mode.computeKWidth(240, 320, 1)
|
|
30
|
+
expect(partial).not.toBeNull()
|
|
31
|
+
expect(full).not.toBeNull()
|
|
32
|
+
expect(partial!.kWidth).toBeCloseTo(full!.kWidth, 8)
|
|
33
|
+
expect(partial!.kGap).toBeCloseTo(full!.kGap, 8)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('setMarketSession switches bar metrics to HK 330 slots', () => {
|
|
37
|
+
const mode = new TimeShareMode()
|
|
38
|
+
const ashare = mode.computeKWidth(100, 330, 1)!
|
|
39
|
+
mode.setMarketSession({
|
|
40
|
+
timeZone: 'Asia/Hong_Kong',
|
|
41
|
+
sessions: [
|
|
42
|
+
{ open: 9 * 60 + 30, close: 12 * 60 },
|
|
43
|
+
{ open: 13 * 60, close: 16 * 60 },
|
|
44
|
+
],
|
|
45
|
+
slotMinutes: 1,
|
|
46
|
+
})
|
|
47
|
+
const hk = mode.computeKWidth(100, 330, 1)!
|
|
48
|
+
// A 股 240 槽 unit=330/240;港股 330 槽 unit=1
|
|
49
|
+
expect(ashare.kWidth + ashare.kGap).toBeCloseTo(330 / 240, 6)
|
|
50
|
+
expect(hk.kWidth + hk.kGap).toBeCloseTo(1, 6)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('updatePaneRange uses preClose as basePrice and covers open gap', () => {
|
|
54
|
+
const mode = new TimeShareMode()
|
|
55
|
+
const pane = new Pane('main')
|
|
56
|
+
const setBase = vi.spyOn(pane.yAxis, 'setBasePrice')
|
|
57
|
+
const setRange = vi.spyOn(pane.yAxis, 'setRange')
|
|
58
|
+
|
|
59
|
+
// open gap: first trade 11, preClose 10
|
|
60
|
+
mode.updatePaneRange(pane, { start: 0, end: 3 }, mockDm([ts(11), ts(10.5), ts(10.2)], 10))
|
|
61
|
+
|
|
62
|
+
expect(setBase).toHaveBeenCalledWith(10)
|
|
63
|
+
expect(setRange).toHaveBeenCalled()
|
|
64
|
+
const range = setRange.mock.calls[0]![0] as { minPrice: number; maxPrice: number }
|
|
65
|
+
// maxAbs 10% + 1% padding → ±11%
|
|
66
|
+
expect(range.maxPrice).toBeCloseTo(11.1, 6)
|
|
67
|
+
expect(range.minPrice).toBeCloseTo(8.9, 6)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('updatePaneRange still sets range on flat day', () => {
|
|
71
|
+
const mode = new TimeShareMode()
|
|
72
|
+
const pane = new Pane('main')
|
|
73
|
+
const setRange = vi.spyOn(pane.yAxis, 'setRange')
|
|
74
|
+
|
|
75
|
+
mode.updatePaneRange(pane, { start: 0, end: 2 }, mockDm([ts(10), ts(10)], 10))
|
|
76
|
+
expect(setRange).toHaveBeenCalled()
|
|
77
|
+
const range = setRange.mock.calls[0]![0] as { minPrice: number; maxPrice: number }
|
|
78
|
+
expect(range.maxPrice).toBeCloseTo(10.05, 6)
|
|
79
|
+
expect(range.minPrice).toBeCloseTo(9.95, 6)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ASHARE_MARKET_SESSION,
|
|
3
|
+
resolveMarketSessionSlots,
|
|
4
|
+
type MarketSessionConfig,
|
|
5
|
+
} from '../../foundation/utils/timeShareAxisLabels'
|
|
6
|
+
|
|
7
|
+
export type TimeShareBaselineInput = {
|
|
8
|
+
preClose?: number | null
|
|
9
|
+
firstPrice?: number | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveTimeShareBaseline(input: TimeShareBaselineInput): number | null {
|
|
13
|
+
const candidates = [input.preClose, input.firstPrice]
|
|
14
|
+
for (const v of candidates) {
|
|
15
|
+
if (typeof v === 'number' && Number.isFinite(v) && v !== 0) return v
|
|
16
|
+
}
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TimeSharePriceRange = {
|
|
21
|
+
minPrice: number
|
|
22
|
+
maxPrice: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 分时 Y 轴价格区间:以 baseline(昨收)为中心,对称覆盖可见最大绝对涨跌幅并加 padding。
|
|
27
|
+
* 全天平盘时仍保留最小 0.5% 边距,避免 range 退化。
|
|
28
|
+
*/
|
|
29
|
+
export function computeTimeSharePriceRange(
|
|
30
|
+
prices: ReadonlyArray<number | undefined | null>,
|
|
31
|
+
baseline: number,
|
|
32
|
+
): TimeSharePriceRange | null {
|
|
33
|
+
if (!Number.isFinite(baseline) || baseline === 0) return null
|
|
34
|
+
|
|
35
|
+
let maxAbsPct = 0
|
|
36
|
+
for (const p of prices) {
|
|
37
|
+
if (typeof p !== 'number' || !Number.isFinite(p)) continue
|
|
38
|
+
const pct = Math.abs((p - baseline) / baseline) * 100
|
|
39
|
+
if (pct > maxAbsPct) maxAbsPct = pct
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const padding = Math.max(maxAbsPct * 0.1, 0.5)
|
|
43
|
+
const displayPct = maxAbsPct + padding
|
|
44
|
+
return {
|
|
45
|
+
minPrice: baseline * (1 - displayPct / 100),
|
|
46
|
+
maxPrice: baseline * (1 + displayPct / 100),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A 股默认全天 1 分钟槽位数(兼容旧导出) */
|
|
51
|
+
export const ASHARE_TIMESHARE_SESSION_SLOTS = resolveMarketSessionSlots(ASHARE_MARKET_SESSION)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 全天交易槽位数:以 marketSession 为 SSOT,不因 arrivedCount 放大。
|
|
55
|
+
* @deprecated 优先用 resolveMarketSessionSlots(config)
|
|
56
|
+
*/
|
|
57
|
+
export function resolveTimeShareSessionSlots(
|
|
58
|
+
arrivedCount: number,
|
|
59
|
+
marketSession: MarketSessionConfig = ASHARE_MARKET_SESSION,
|
|
60
|
+
): number {
|
|
61
|
+
void arrivedCount
|
|
62
|
+
return resolveMarketSessionSlots(marketSession)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 分时 bar 宽度:按全天 sessionSlots 均分 viewWidth,而非按已到达点数。
|
|
67
|
+
* 盘中部分数据时右侧留白;允许亚像素,避免窄屏截断。
|
|
68
|
+
*/
|
|
69
|
+
export function computeTimeShareBarMetrics(
|
|
70
|
+
dataLength: number,
|
|
71
|
+
viewWidth: number,
|
|
72
|
+
dpr: number,
|
|
73
|
+
marketSession: MarketSessionConfig = ASHARE_MARKET_SESSION,
|
|
74
|
+
): { kWidth: number; kGap: number } | null {
|
|
75
|
+
if (dataLength <= 0 || viewWidth <= 0 || !(dpr > 0)) return null
|
|
76
|
+
|
|
77
|
+
const sessionSlots = resolveMarketSessionSlots(marketSession)
|
|
78
|
+
if (sessionSlots <= 0) return null
|
|
79
|
+
const unit = viewWidth / sessionSlots
|
|
80
|
+
const preferredGap = 1 / dpr
|
|
81
|
+
const kGap = Math.min(preferredGap, unit * 0.2)
|
|
82
|
+
const kWidth = Math.max(unit - kGap, unit * 0.01)
|
|
83
|
+
return { kWidth, kGap: unit - kWidth }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type TimeShareXLayoutInput = {
|
|
87
|
+
arrivedCount: number
|
|
88
|
+
sessionSlots: number
|
|
89
|
+
totalWidth: number
|
|
90
|
+
dpr: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type TimeShareXLayout = {
|
|
94
|
+
step: number
|
|
95
|
+
centers: number[]
|
|
96
|
+
lefts: number[]
|
|
97
|
+
barWidth: number
|
|
98
|
+
kWidthPx: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 分时 X 布局:step = totalWidth / sessionSlots,已到达点按 slot 索引落位,未到时段留白。
|
|
103
|
+
*/
|
|
104
|
+
export function computeTimeShareXLayout(input: TimeShareXLayoutInput): TimeShareXLayout | null {
|
|
105
|
+
const { arrivedCount, sessionSlots, totalWidth, dpr } = input
|
|
106
|
+
if (arrivedCount <= 0 || sessionSlots <= 0 || totalWidth <= 0 || !(dpr > 0)) return null
|
|
107
|
+
|
|
108
|
+
const step = totalWidth / sessionSlots
|
|
109
|
+
const centers: number[] = new Array(arrivedCount)
|
|
110
|
+
const lefts: number[] = new Array(arrivedCount)
|
|
111
|
+
for (let i = 0; i < arrivedCount; i++) {
|
|
112
|
+
centers[i] = Math.round((i + 0.5) * step * dpr) / dpr
|
|
113
|
+
lefts[i] = Math.round(i * step * dpr) / dpr
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const logicalBarWidth = Math.max(step * 0.6, 1 / dpr)
|
|
117
|
+
const barWidthPx = Math.max(1, Math.round(logicalBarWidth * dpr))
|
|
118
|
+
const barWidth = barWidthPx / dpr
|
|
119
|
+
const kWidthPx = Math.max(1, Math.round(step * dpr))
|
|
120
|
+
|
|
121
|
+
return { step, centers, lefts, barWidth, kWidthPx }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type TimeSharePaneLayout = {
|
|
125
|
+
priceTop: number
|
|
126
|
+
priceAreaHeight: number
|
|
127
|
+
volumeTop: number
|
|
128
|
+
volumeAreaHeight: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function computeTimeSharePaneLayout(
|
|
132
|
+
paneHeight: number,
|
|
133
|
+
volumeRatio: number,
|
|
134
|
+
): TimeSharePaneLayout {
|
|
135
|
+
const ratio = Math.min(1, Math.max(0, volumeRatio))
|
|
136
|
+
const volumeAreaHeight = paneHeight * ratio
|
|
137
|
+
const priceAreaHeight = paneHeight - volumeAreaHeight
|
|
138
|
+
return {
|
|
139
|
+
priceTop: 0,
|
|
140
|
+
priceAreaHeight,
|
|
141
|
+
volumeTop: priceAreaHeight,
|
|
142
|
+
volumeAreaHeight,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export {
|
|
147
|
+
TIMESHARE_MIN_LABEL_SPACING_PX,
|
|
148
|
+
computeTimeShareTimeLabelIndices,
|
|
149
|
+
type TimeShareTimeLabelInput,
|
|
150
|
+
} from '../../foundation/utils/timeShareAxisLabels'
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { ChartDataManager } from '../data/chartDataManager'
|
|
2
2
|
import type { Pane, VisibleRange } from '../layout/pane'
|
|
3
3
|
|
|
4
|
+
import type { MarketSessionConfig } from '../../foundation/utils/timeShareAxisLabels'
|
|
5
|
+
import { ASHARE_MARKET_SESSION } from '../../foundation/utils/timeShareAxisLabels'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
computeTimeShareBarMetrics,
|
|
9
|
+
computeTimeSharePriceRange,
|
|
10
|
+
resolveTimeShareBaseline,
|
|
11
|
+
} from './timeShareMath'
|
|
4
12
|
import type { ChartModeHandler } from './types'
|
|
5
13
|
|
|
6
14
|
export class TimeShareMode implements ChartModeHandler {
|
|
@@ -12,6 +20,17 @@ export class TimeShareMode implements ChartModeHandler {
|
|
|
12
20
|
readonly allowRightAxisScale = false
|
|
13
21
|
readonly useIndicatorScheduler = false
|
|
14
22
|
|
|
23
|
+
/** 市场 session;默认 A 股,可 setMarketSession 切换 */
|
|
24
|
+
private _marketSession: MarketSessionConfig = ASHARE_MARKET_SESSION
|
|
25
|
+
|
|
26
|
+
get marketSession(): MarketSessionConfig {
|
|
27
|
+
return this._marketSession
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setMarketSession(config: MarketSessionConfig): void {
|
|
31
|
+
this._marketSession = config
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
computeContentWidth(
|
|
16
35
|
_dataLength: number,
|
|
17
36
|
leftBufferWidth: number,
|
|
@@ -27,17 +46,7 @@ export class TimeShareMode implements ChartModeHandler {
|
|
|
27
46
|
viewWidth: number,
|
|
28
47
|
dpr: number,
|
|
29
48
|
): { kWidth: number; kGap: number } | null {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const kGapPx = 1
|
|
33
|
-
const totalGapPx = (dataLength + 1) * kGapPx
|
|
34
|
-
const availablePx = Math.max(1, viewWidth * dpr - totalGapPx)
|
|
35
|
-
const kWidthPx = Math.max(1, Math.floor(availablePx / dataLength))
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
kWidth: kWidthPx / dpr,
|
|
39
|
-
kGap: kGapPx / dpr,
|
|
40
|
-
}
|
|
49
|
+
return computeTimeShareBarMetrics(dataLength, viewWidth, dpr, this._marketSession)
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
updatePaneRange(
|
|
@@ -47,30 +56,28 @@ export class TimeShareMode implements ChartModeHandler {
|
|
|
47
56
|
_mergedIndicatorRange?: { min: number; max: number } | null,
|
|
48
57
|
): void {
|
|
49
58
|
const tsData = dm.getTimeShareData()
|
|
50
|
-
const end = Math.min(range.end, tsData.length)
|
|
51
59
|
if (tsData.length === 0) return
|
|
52
60
|
|
|
53
|
-
const
|
|
54
|
-
|
|
61
|
+
const end = Math.min(range.end, tsData.length)
|
|
62
|
+
const start = Math.max(0, range.start)
|
|
63
|
+
// 优先昨收;缺失时回退首笔价
|
|
64
|
+
const baseline = resolveTimeShareBaseline({
|
|
65
|
+
preClose: dm.getTimeSharePreClose(),
|
|
66
|
+
firstPrice: tsData[0]?.price,
|
|
67
|
+
})
|
|
68
|
+
if (baseline === null) return
|
|
55
69
|
|
|
56
70
|
// scaleType 由 kernel.paneScaleTypes 投影(进入 timeshare 时写 percent);此处只设会话 basePrice
|
|
57
71
|
pane.yAxis.setBasePrice(baseline)
|
|
58
72
|
|
|
59
|
-
|
|
60
|
-
for (let i =
|
|
73
|
+
const prices: number[] = []
|
|
74
|
+
for (let i = start; i < end; i++) {
|
|
61
75
|
const p = tsData[i]?.price
|
|
62
|
-
if (p !== undefined
|
|
63
|
-
const pct = Math.abs((p - baseline) / baseline) * 100
|
|
64
|
-
if (pct > maxAbsPct) maxAbsPct = pct
|
|
65
|
-
}
|
|
76
|
+
if (p !== undefined) prices.push(p)
|
|
66
77
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const displayPct = maxAbsPct + padding
|
|
71
|
-
const minPrice = baseline * (1 - displayPct / 100)
|
|
72
|
-
const maxPrice = baseline * (1 + displayPct / 100)
|
|
73
|
-
pane.yAxis.setRange({ maxPrice, minPrice })
|
|
78
|
+
const priceRange = computeTimeSharePriceRange(prices, baseline)
|
|
79
|
+
if (!priceRange) return
|
|
80
|
+
pane.yAxis.setRange(priceRange)
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
onActivate(
|
|
@@ -103,6 +110,6 @@ export class TimeShareMode implements ChartModeHandler {
|
|
|
103
110
|
_next: ChartModeHandler | null,
|
|
104
111
|
): void {
|
|
105
112
|
chart.disableMainIndicator('timeShare')
|
|
106
|
-
|
|
113
|
+
// candle 可见性由 Chart.setActiveMode 按进入前快照恢复,此处不强制 true
|
|
107
114
|
}
|
|
108
115
|
}
|
|
@@ -31,6 +31,9 @@ import { ChartIndicatorManager } from '../indicators/chartIndicatorManager'
|
|
|
31
31
|
import { UpdateLevel } from '../layout/pane'
|
|
32
32
|
import type { VisibleRange } from '../layout/pane'
|
|
33
33
|
import { MarkerManager, type CustomMarkerEntity, type MarkerManagerDeps } from '../marker/registry'
|
|
34
|
+
import { ASHARE_MARKET_SESSION } from '../../foundation/utils/timeShareAxisLabels'
|
|
35
|
+
import { resolveMarketSessionSlots } from '../../foundation/utils/timeShareAxisLabels'
|
|
36
|
+
import { computeTimeShareXLayout } from '../modes/timeShareMath'
|
|
34
37
|
import type { ChartModeHandler } from '../modes/types'
|
|
35
38
|
import { PaneRenderer } from '../paneRenderer'
|
|
36
39
|
import { createTimeAxisRendererPlugin } from '../renderers/timeAxis'
|
|
@@ -588,29 +591,31 @@ export class ChartRenderer {
|
|
|
588
591
|
kBarRects[i] = { x: barLeftPx / vp.dpr, width: barWidthPx / vp.dpr }
|
|
589
592
|
}
|
|
590
593
|
|
|
591
|
-
// TimeShare
|
|
594
|
+
// TimeShare:按全天 sessionSlots 划分宽度,已到达点落在时间线上,未到时段右侧留白
|
|
592
595
|
if (mode.debugName === 'TimeShare') {
|
|
593
|
-
const totalWidth = vp.plotWidth
|
|
594
596
|
const count = kLineCenters.length
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const halfBarPx = Math.floor(
|
|
597
|
+
const marketSession =
|
|
598
|
+
'marketSession' in mode && mode.marketSession
|
|
599
|
+
? (mode as { marketSession: typeof ASHARE_MARKET_SESSION }).marketSession
|
|
600
|
+
: ASHARE_MARKET_SESSION
|
|
601
|
+
const layout = computeTimeShareXLayout({
|
|
602
|
+
arrivedCount: count,
|
|
603
|
+
sessionSlots: resolveMarketSessionSlots(marketSession),
|
|
604
|
+
totalWidth: vp.plotWidth,
|
|
605
|
+
dpr: vp.dpr,
|
|
606
|
+
})
|
|
607
|
+
if (layout) {
|
|
608
|
+
const halfBarPx = Math.floor((layout.barWidth * vp.dpr) / 2)
|
|
607
609
|
for (let i = 0; i < count; i++) {
|
|
608
|
-
|
|
610
|
+
kLineCenters[i] = layout.centers[i]!
|
|
611
|
+
kLinePositions[i] = layout.lefts[i]!
|
|
612
|
+
const centerPx = Math.round(layout.centers[i]! * vp.dpr)
|
|
609
613
|
kBarRects[i] = {
|
|
610
|
-
x: (centerPx - halfBarPx) / dpr,
|
|
611
|
-
width:
|
|
614
|
+
x: (centerPx - halfBarPx) / vp.dpr,
|
|
615
|
+
width: layout.barWidth,
|
|
612
616
|
}
|
|
613
617
|
}
|
|
618
|
+
kWidthPx = layout.kWidthPx
|
|
614
619
|
} else {
|
|
615
620
|
kWidthPx = getPhysicalKLineConfig(opt.kWidth, opt.kGap, vp.dpr).kWidthPx
|
|
616
621
|
}
|
|
@@ -826,7 +831,13 @@ export class ChartRenderer {
|
|
|
826
831
|
plotWidth: vp.plotWidth,
|
|
827
832
|
plotHeight: vp.plotHeight,
|
|
828
833
|
},
|
|
829
|
-
settings:
|
|
834
|
+
settings: {
|
|
835
|
+
...this.settings,
|
|
836
|
+
// 分时昨收优先读 series 元数据,settings 作回退
|
|
837
|
+
preClose:
|
|
838
|
+
dataManager.getTimeSharePreClose() ??
|
|
839
|
+
(this.settings.preClose as number | undefined),
|
|
840
|
+
},
|
|
830
841
|
yAxisLabels: sharedYAxisLabels,
|
|
831
842
|
xAxisLabels: sharedXAxisLabels,
|
|
832
843
|
yAxisRanges: sharedYAxisRanges,
|