@forgecharts/sdk 1.1.23
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/package.json +50 -0
- package/src/__tests__/backwardCompatibility.test.ts +191 -0
- package/src/__tests__/candleInvariant.test.ts +500 -0
- package/src/__tests__/public-api-surface.ts +76 -0
- package/src/__tests__/timeframeBoundary.test.ts +583 -0
- package/src/api/DrawingManager.ts +188 -0
- package/src/api/EventBus.ts +53 -0
- package/src/api/IndicatorDAG.ts +389 -0
- package/src/api/IndicatorRegistry.ts +47 -0
- package/src/api/LayoutManager.ts +72 -0
- package/src/api/PaneManager.ts +129 -0
- package/src/api/ReferenceAPI.ts +195 -0
- package/src/api/TChart.ts +881 -0
- package/src/api/createChart.ts +43 -0
- package/src/api/drawing tools/fib gann menu/fibRetracement.ts +27 -0
- package/src/api/drawing tools/lines menu/crossLine.ts +21 -0
- package/src/api/drawing tools/lines menu/disjointChannel.ts +74 -0
- package/src/api/drawing tools/lines menu/extendedLine.ts +22 -0
- package/src/api/drawing tools/lines menu/flatTopBottom.ts +45 -0
- package/src/api/drawing tools/lines menu/horizontal.ts +24 -0
- package/src/api/drawing tools/lines menu/horizontalRay.ts +25 -0
- package/src/api/drawing tools/lines menu/infoLine.ts +127 -0
- package/src/api/drawing tools/lines menu/insidePitchfork.ts +21 -0
- package/src/api/drawing tools/lines menu/modifiedSchiffPitchfork.ts +18 -0
- package/src/api/drawing tools/lines menu/parallelChannel.ts +47 -0
- package/src/api/drawing tools/lines menu/pitchfork.ts +15 -0
- package/src/api/drawing tools/lines menu/ray.ts +28 -0
- package/src/api/drawing tools/lines menu/regressionTrend.ts +157 -0
- package/src/api/drawing tools/lines menu/schiffPitchfork.ts +18 -0
- package/src/api/drawing tools/lines menu/trendAngle.ts +64 -0
- package/src/api/drawing tools/lines menu/trendline.ts +16 -0
- package/src/api/drawing tools/lines menu/vertical.ts +16 -0
- package/src/api/drawing tools/pointers menu/crosshair.ts +17 -0
- package/src/api/drawing tools/pointers menu/cursor.ts +16 -0
- package/src/api/drawing tools/pointers menu/demonstration.ts +35 -0
- package/src/api/drawing tools/pointers menu/dot.ts +26 -0
- package/src/api/drawing tools/shapes menu/rectangle.ts +24 -0
- package/src/api/drawing tools/shapes menu/text.ts +30 -0
- package/src/api/drawingUtils.ts +82 -0
- package/src/core/CanvasLayer.ts +77 -0
- package/src/core/Chart.ts +917 -0
- package/src/core/CoordTransform.ts +282 -0
- package/src/core/Crosshair.ts +207 -0
- package/src/core/IndicatorEngine.ts +216 -0
- package/src/core/InteractionManager.ts +899 -0
- package/src/core/PriceScale.ts +133 -0
- package/src/core/Series.ts +132 -0
- package/src/core/TimeScale.ts +175 -0
- package/src/datafeed/DatafeedConnector.ts +300 -0
- package/src/engine/CandleEngine.ts +458 -0
- package/src/engine/__tests__/CandleEngine.test.ts +402 -0
- package/src/engine/candleInvariants.ts +172 -0
- package/src/engine/mergeUtils.ts +93 -0
- package/src/engine/timeframeUtils.ts +118 -0
- package/src/index.ts +190 -0
- package/src/internal.ts +41 -0
- package/src/licensing/ChartRuntimeResolver.ts +380 -0
- package/src/licensing/LicenseManager.ts +131 -0
- package/src/licensing/__tests__/ChartRuntimeResolver.test.ts +207 -0
- package/src/licensing/__tests__/LicenseManager.test.ts +180 -0
- package/src/licensing/licenseTypes.ts +19 -0
- package/src/pine/PineCompiler.ts +68 -0
- package/src/pine/diagnostics.ts +30 -0
- package/src/pine/index.ts +7 -0
- package/src/pine/pine-ast.ts +163 -0
- package/src/pine/pine-lexer.ts +265 -0
- package/src/pine/pine-parser.ts +439 -0
- package/src/pine/pine-transpiler.ts +301 -0
- package/src/pixi/LayerName.ts +35 -0
- package/src/pixi/PixiCandlestickRenderer.ts +125 -0
- package/src/pixi/PixiChart.ts +425 -0
- package/src/pixi/PixiCrosshairRenderer.ts +134 -0
- package/src/pixi/PixiDrawingRenderer.ts +121 -0
- package/src/pixi/PixiGridRenderer.ts +136 -0
- package/src/pixi/PixiLayerManager.ts +102 -0
- package/src/renderers/CandlestickRenderer.ts +130 -0
- package/src/renderers/HistogramRenderer.ts +63 -0
- package/src/renderers/LineRenderer.ts +77 -0
- package/src/theme/colors.ts +21 -0
- package/src/tools/barDivergenceCheck.ts +305 -0
- package/src/trading/TradingOverlayStore.ts +161 -0
- package/src/trading/UnmanagedIngestion.ts +156 -0
- package/src/trading/__tests__/ManagedTradingController.test.ts +338 -0
- package/src/trading/__tests__/TradingOverlayStore.test.ts +323 -0
- package/src/trading/__tests__/UnmanagedIngestion.test.ts +205 -0
- package/src/trading/managed/ManagedTradingController.ts +292 -0
- package/src/trading/managed/managedCapabilities.ts +98 -0
- package/src/trading/managed/managedTypes.ts +151 -0
- package/src/trading/tradingTypes.ts +135 -0
- package/src/tscript/TScriptIndicator.ts +54 -0
- package/src/tscript/ast.ts +105 -0
- package/src/tscript/lexer.ts +190 -0
- package/src/tscript/parser.ts +334 -0
- package/src/tscript/runtime.ts +525 -0
- package/src/tscript/series.ts +84 -0
- package/src/types/IChart.ts +56 -0
- package/src/types/IRenderer.ts +16 -0
- package/src/types/ISeries.ts +30 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +15 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeframe Boundary Tests
|
|
3
|
+
*
|
|
4
|
+
* Verifies correctness of `getBucketStart` (from `@forgecharts/shared`) and the
|
|
5
|
+
* `CandleEngine` live-update / reconnect behaviour at every critical boundary.
|
|
6
|
+
*
|
|
7
|
+
* Test categories:
|
|
8
|
+
* 1. Fixed intervals — standard and previously-missing timeframes
|
|
9
|
+
* 2. Weekly alignment — Monday start (ISO 8601)
|
|
10
|
+
* 3. Monthly alignment — calendar first-of-month, incl. leap-year February
|
|
11
|
+
* 4. Live update semantics — open immutability, high/low expansion, close,
|
|
12
|
+
* same-bucket vs new-bucket
|
|
13
|
+
* 5. Reconnect — gap backfill, historical bars override provisional,
|
|
14
|
+
* forming candle survives reconnect
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
18
|
+
import {
|
|
19
|
+
getBucketStart,
|
|
20
|
+
getWeekBucketStart,
|
|
21
|
+
getMonthBucketStart,
|
|
22
|
+
isSameBucket,
|
|
23
|
+
normalizeTimestamp,
|
|
24
|
+
timeframeToMs,
|
|
25
|
+
TIMEFRAMES,
|
|
26
|
+
} from '@forgecharts/shared/time';
|
|
27
|
+
import { CandleEngine } from '../engine/CandleEngine';
|
|
28
|
+
import type { RawOHLCV } from '../engine/CandleEngine';
|
|
29
|
+
|
|
30
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Parse an ISO-8601 UTC string to Unix milliseconds. */
|
|
33
|
+
const ms = (iso: string): number => new Date(iso).getTime();
|
|
34
|
+
/** Parse an ISO-8601 UTC string to Unix seconds. */
|
|
35
|
+
const sec = (iso: string): number => new Date(iso).getTime() / 1000;
|
|
36
|
+
|
|
37
|
+
/** Build a minimal RawOHLCV tick (all values in Unix seconds by default). */
|
|
38
|
+
function tick(
|
|
39
|
+
timeSecOrMs: number,
|
|
40
|
+
open = 100,
|
|
41
|
+
high = 110,
|
|
42
|
+
low = 90,
|
|
43
|
+
close = 105,
|
|
44
|
+
volume = 1,
|
|
45
|
+
): RawOHLCV {
|
|
46
|
+
return { time: timeSecOrMs, open, high, low, close, volume };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── 1. Fixed intervals ───────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe('getBucketStart — fixed intervals', () => {
|
|
52
|
+
/**
|
|
53
|
+
* Reference point: 2025-03-09 14:37:42 UTC
|
|
54
|
+
* ms = 1741528662000
|
|
55
|
+
* sec = 1741528662
|
|
56
|
+
*/
|
|
57
|
+
const REF_MS = ms('2025-03-09T14:37:42Z');
|
|
58
|
+
|
|
59
|
+
it('1m: floors to the minute boundary', () => {
|
|
60
|
+
expect(getBucketStart(REF_MS, '1m')).toBe(ms('2025-03-09T14:37:00Z'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('2m: floors to the 2-minute boundary', () => {
|
|
64
|
+
// 14:37 → bucket start is 14:36 (36 is divisible by 2)
|
|
65
|
+
expect(getBucketStart(REF_MS, '2m')).toBe(ms('2025-03-09T14:36:00Z'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('3m (previously missing): floors correctly', () => {
|
|
69
|
+
// 14:37 → bucket start is 14:36 (36 is divisible by 3)
|
|
70
|
+
expect(getBucketStart(REF_MS, '3m')).toBe(ms('2025-03-09T14:36:00Z'));
|
|
71
|
+
// 14:37:42 inside [14:36, 14:39) — confirm
|
|
72
|
+
expect(getBucketStart(ms('2025-03-09T14:38:59Z'), '3m')).toBe(ms('2025-03-09T14:36:00Z'));
|
|
73
|
+
expect(getBucketStart(ms('2025-03-09T14:39:00Z'), '3m')).toBe(ms('2025-03-09T14:39:00Z'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('4m (previously missing): floors correctly', () => {
|
|
77
|
+
// 14:37 → bucket start is 14:36 (36 is divisible by 4)
|
|
78
|
+
expect(getBucketStart(REF_MS, '4m')).toBe(ms('2025-03-09T14:36:00Z'));
|
|
79
|
+
expect(getBucketStart(ms('2025-03-09T14:39:59Z'), '4m')).toBe(ms('2025-03-09T14:36:00Z'));
|
|
80
|
+
expect(getBucketStart(ms('2025-03-09T14:40:00Z'), '4m')).toBe(ms('2025-03-09T14:40:00Z'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('5m: floors to the 5-minute boundary', () => {
|
|
84
|
+
expect(getBucketStart(REF_MS, '5m')).toBe(ms('2025-03-09T14:35:00Z'));
|
|
85
|
+
expect(getBucketStart(ms('2025-03-09T14:39:59Z'), '5m')).toBe(ms('2025-03-09T14:35:00Z'));
|
|
86
|
+
expect(getBucketStart(ms('2025-03-09T14:40:00Z'), '5m')).toBe(ms('2025-03-09T14:40:00Z'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('10m (previously missing): floors correctly', () => {
|
|
90
|
+
expect(getBucketStart(REF_MS, '10m')).toBe(ms('2025-03-09T14:30:00Z'));
|
|
91
|
+
expect(getBucketStart(ms('2025-03-09T14:39:59Z'), '10m')).toBe(ms('2025-03-09T14:30:00Z'));
|
|
92
|
+
expect(getBucketStart(ms('2025-03-09T14:40:00Z'), '10m')).toBe(ms('2025-03-09T14:40:00Z'));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('15m: floors to the 15-minute boundary', () => {
|
|
96
|
+
expect(getBucketStart(REF_MS, '15m')).toBe(ms('2025-03-09T14:30:00Z'));
|
|
97
|
+
expect(getBucketStart(ms('2025-03-09T14:44:59Z'), '15m')).toBe(ms('2025-03-09T14:30:00Z'));
|
|
98
|
+
expect(getBucketStart(ms('2025-03-09T14:45:00Z'), '15m')).toBe(ms('2025-03-09T14:45:00Z'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('20m (previously missing): floors correctly', () => {
|
|
102
|
+
expect(getBucketStart(REF_MS, '20m')).toBe(ms('2025-03-09T14:20:00Z'));
|
|
103
|
+
expect(getBucketStart(ms('2025-03-09T14:39:59Z'), '20m')).toBe(ms('2025-03-09T14:20:00Z'));
|
|
104
|
+
expect(getBucketStart(ms('2025-03-09T14:40:00Z'), '20m')).toBe(ms('2025-03-09T14:40:00Z'));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('30m: floors to the 30-minute boundary', () => {
|
|
108
|
+
expect(getBucketStart(REF_MS, '30m')).toBe(ms('2025-03-09T14:30:00Z'));
|
|
109
|
+
expect(getBucketStart(ms('2025-03-09T14:59:59Z'), '30m')).toBe(ms('2025-03-09T14:30:00Z'));
|
|
110
|
+
expect(getBucketStart(ms('2025-03-09T15:00:00Z'), '30m')).toBe(ms('2025-03-09T15:00:00Z'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('45m (previously missing): floors correctly', () => {
|
|
114
|
+
// 14:37 → bucket start is 14:00 (14*60=840, 840/45=18.666 → floor*45=810=13:30)?
|
|
115
|
+
// Actually 14:00 = 840 min from epoch day. 840 / 45 = 18.666... → floor = 18 → 18*45 = 810 min
|
|
116
|
+
// 810 min = 13:30. So 14:37 falls in the [13:30, 14:15) bucket.
|
|
117
|
+
// Wait — getBucketStart uses epoch ms, not minutes within a day.
|
|
118
|
+
// Let me recalculate: epoch for 14:37 on 2025-03-09, then floor to 45-min intervals.
|
|
119
|
+
const bucket = getBucketStart(REF_MS, '45m');
|
|
120
|
+
const INTERVAL = TIMEFRAMES['45m']!.ms!; // 2_700_000
|
|
121
|
+
expect(bucket % INTERVAL).toBe(0); // must be on a 45-min epoch boundary
|
|
122
|
+
expect(bucket).toBeLessThanOrEqual(REF_MS);
|
|
123
|
+
expect(bucket + INTERVAL).toBeGreaterThan(REF_MS);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('1h: floors to the hour boundary', () => {
|
|
127
|
+
expect(getBucketStart(REF_MS, '1h')).toBe(ms('2025-03-09T14:00:00Z'));
|
|
128
|
+
expect(getBucketStart(ms('2025-03-09T14:59:59Z'), '1h')).toBe(ms('2025-03-09T14:00:00Z'));
|
|
129
|
+
expect(getBucketStart(ms('2025-03-09T15:00:00Z'), '1h')).toBe(ms('2025-03-09T15:00:00Z'));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('2h: floors to the 2-hour boundary', () => {
|
|
133
|
+
expect(getBucketStart(REF_MS, '2h')).toBe(ms('2025-03-09T14:00:00Z'));
|
|
134
|
+
expect(getBucketStart(ms('2025-03-09T15:59:59Z'), '2h')).toBe(ms('2025-03-09T14:00:00Z'));
|
|
135
|
+
expect(getBucketStart(ms('2025-03-09T16:00:00Z'), '2h')).toBe(ms('2025-03-09T16:00:00Z'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('4h: floors to the 4-hour boundary', () => {
|
|
139
|
+
expect(getBucketStart(REF_MS, '4h')).toBe(ms('2025-03-09T12:00:00Z'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('6h: floors to the 6-hour boundary', () => {
|
|
143
|
+
expect(getBucketStart(REF_MS, '6h')).toBe(ms('2025-03-09T12:00:00Z'));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('12h: floors to the 12-hour boundary', () => {
|
|
147
|
+
expect(getBucketStart(REF_MS, '12h')).toBe(ms('2025-03-09T12:00:00Z'));
|
|
148
|
+
expect(getBucketStart(ms('2025-03-09T11:59:59Z'), '12h')).toBe(ms('2025-03-09T00:00:00Z'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('1d: floors to UTC midnight', () => {
|
|
152
|
+
expect(getBucketStart(REF_MS, '1d')).toBe(ms('2025-03-09T00:00:00Z'));
|
|
153
|
+
expect(getBucketStart(ms('2025-03-09T23:59:59Z'), '1d')).toBe(ms('2025-03-09T00:00:00Z'));
|
|
154
|
+
expect(getBucketStart(ms('2025-03-10T00:00:00Z'), '1d')).toBe(ms('2025-03-10T00:00:00Z'));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('3d: floors to the 3-day epoch boundary', () => {
|
|
158
|
+
const bucket = getBucketStart(REF_MS, '3d');
|
|
159
|
+
const INTERVAL = TIMEFRAMES['3d']!.ms!; // 259_200_000
|
|
160
|
+
expect(bucket % INTERVAL).toBe(0);
|
|
161
|
+
expect(bucket).toBeLessThanOrEqual(REF_MS);
|
|
162
|
+
expect(bucket + INTERVAL).toBeGreaterThan(REF_MS);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('exact boundary: timestamp == bucket start maps to itself', () => {
|
|
166
|
+
const hourStart = ms('2025-03-09T14:00:00Z');
|
|
167
|
+
expect(getBucketStart(hourStart, '1h')).toBe(hourStart);
|
|
168
|
+
|
|
169
|
+
const minuteStart = ms('2025-03-09T14:37:00Z');
|
|
170
|
+
expect(getBucketStart(minuteStart, '1m')).toBe(minuteStart);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('isSameBucket: two timestamps in the same 1h bucket', () => {
|
|
174
|
+
expect(isSameBucket(
|
|
175
|
+
ms('2025-03-09T14:01:00Z'),
|
|
176
|
+
ms('2025-03-09T14:59:00Z'),
|
|
177
|
+
'1h',
|
|
178
|
+
)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('isSameBucket: timestamps on either side of an hour boundary', () => {
|
|
182
|
+
expect(isSameBucket(
|
|
183
|
+
ms('2025-03-09T13:59:59Z'),
|
|
184
|
+
ms('2025-03-09T14:00:01Z'),
|
|
185
|
+
'1h',
|
|
186
|
+
)).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('timeframeToMs: returns null for calendar types and unknown keys', () => {
|
|
190
|
+
expect(timeframeToMs('1w')).toBeNull();
|
|
191
|
+
expect(timeframeToMs('1M')).toBeNull();
|
|
192
|
+
expect(timeframeToMs('3h')).toBeNull(); // orphaned — excluded from registry
|
|
193
|
+
expect(timeframeToMs('8h')).toBeNull(); // orphaned — excluded from registry
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('timeframeToMs: returns correct ms for all fixed timeframes', () => {
|
|
197
|
+
expect(timeframeToMs('1m')).toBe(60_000);
|
|
198
|
+
expect(timeframeToMs('5m')).toBe(300_000);
|
|
199
|
+
expect(timeframeToMs('15m')).toBe(900_000);
|
|
200
|
+
expect(timeframeToMs('1h')).toBe(3_600_000);
|
|
201
|
+
expect(timeframeToMs('4h')).toBe(14_400_000);
|
|
202
|
+
expect(timeframeToMs('1d')).toBe(86_400_000);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ─── 2. Weekly alignment ──────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe('getBucketStart — weekly (Monday alignment)', () => {
|
|
209
|
+
it('Sunday maps to the immediately preceding Monday', () => {
|
|
210
|
+
// 2025-03-09 is a Sunday → bucket should be Mon 2025-03-03
|
|
211
|
+
expect(getWeekBucketStart(ms('2025-03-09T12:00:00Z'))).toBe(ms('2025-03-03T00:00:00Z'));
|
|
212
|
+
expect(getBucketStart(ms('2025-03-09T12:00:00Z'), '1w')).toBe(ms('2025-03-03T00:00:00Z'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('Monday midnight maps to itself', () => {
|
|
216
|
+
expect(getWeekBucketStart(ms('2025-03-03T00:00:00Z'))).toBe(ms('2025-03-03T00:00:00Z'));
|
|
217
|
+
expect(getBucketStart(ms('2025-03-03T00:00:00Z'), '1w')).toBe(ms('2025-03-03T00:00:00Z'));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('Monday 00:00:01 maps to the same Monday', () => {
|
|
221
|
+
expect(getBucketStart(ms('2025-03-03T00:00:01Z'), '1w')).toBe(ms('2025-03-03T00:00:00Z'));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('Saturday 23:59:59 maps to the week-opening Monday', () => {
|
|
225
|
+
expect(getBucketStart(ms('2025-03-08T23:59:59Z'), '1w')).toBe(ms('2025-03-03T00:00:00Z'));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('does NOT anchor to Thursday (Unix epoch)', () => {
|
|
229
|
+
// The naive floor(t / 604800)*604800 anchors to Thursday 1970-01-01.
|
|
230
|
+
// Our implementation must yield a Monday for any input.
|
|
231
|
+
for (const iso of [
|
|
232
|
+
'2025-01-01T00:00:00Z', // Wednesday
|
|
233
|
+
'2025-02-15T12:00:00Z', // Saturday
|
|
234
|
+
'2024-12-25T00:00:00Z', // Wednesday (Christmas)
|
|
235
|
+
'2025-03-09T23:59:59Z', // Sunday
|
|
236
|
+
]) {
|
|
237
|
+
const bucket = getWeekBucketStart(ms(iso));
|
|
238
|
+
const day = new Date(bucket).getUTCDay();
|
|
239
|
+
expect(day, `${iso} → bucket day should be Monday (1), got ${day}`).toBe(1);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('successive week buckets are exactly 7 days apart', () => {
|
|
244
|
+
const w1 = getWeekBucketStart(ms('2025-03-03T00:00:00Z'));
|
|
245
|
+
const w2 = getWeekBucketStart(ms('2025-03-10T00:00:00Z'));
|
|
246
|
+
expect(w2 - w1).toBe(7 * 24 * 60 * 60 * 1000);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('isSameBucket: Monday and Sunday of the same ISO week', () => {
|
|
250
|
+
expect(isSameBucket(
|
|
251
|
+
ms('2025-03-03T00:00:00Z'), // Monday
|
|
252
|
+
ms('2025-03-09T23:59:59Z'), // Sunday
|
|
253
|
+
'1w',
|
|
254
|
+
)).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('isSameBucket: Sunday and the next Monday are different buckets', () => {
|
|
258
|
+
expect(isSameBucket(
|
|
259
|
+
ms('2025-03-09T23:59:59Z'), // Sunday (week of Mar-03)
|
|
260
|
+
ms('2025-03-10T00:00:00Z'), // Monday (next week)
|
|
261
|
+
'1w',
|
|
262
|
+
)).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ─── 3. Monthly alignment ─────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
describe('getBucketStart — monthly (calendar first-of-month)', () => {
|
|
269
|
+
it('mid-month maps to the first of that month', () => {
|
|
270
|
+
expect(getMonthBucketStart(ms('2025-03-15T18:30:00Z'))).toBe(ms('2025-03-01T00:00:00Z'));
|
|
271
|
+
expect(getBucketStart(ms('2025-03-15T18:30:00Z'), '1M')).toBe(ms('2025-03-01T00:00:00Z'));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('first of month 00:00 UTC maps to itself', () => {
|
|
275
|
+
expect(getBucketStart(ms('2025-03-01T00:00:00Z'), '1M')).toBe(ms('2025-03-01T00:00:00Z'));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('last second of January maps to January 1st', () => {
|
|
279
|
+
expect(getBucketStart(ms('2025-01-31T23:59:59Z'), '1M')).toBe(ms('2025-01-01T00:00:00Z'));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('January → February rollover (Feb 1st maps to Feb 1st)', () => {
|
|
283
|
+
expect(getBucketStart(ms('2025-02-01T00:00:00Z'), '1M')).toBe(ms('2025-02-01T00:00:00Z'));
|
|
284
|
+
expect(getBucketStart(ms('2025-01-31T23:59:59Z'), '1M')).toBe(ms('2025-01-01T00:00:00Z'));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('February (non-leap): entire month maps to Feb 1', () => {
|
|
288
|
+
expect(getBucketStart(ms('2025-02-14T12:00:00Z'), '1M')).toBe(ms('2025-02-01T00:00:00Z'));
|
|
289
|
+
expect(getBucketStart(ms('2025-02-28T23:59:59Z'), '1M')).toBe(ms('2025-02-01T00:00:00Z'));
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('February (leap year 2024): Feb 29 maps to Feb 1', () => {
|
|
293
|
+
expect(getBucketStart(ms('2024-02-29T12:00:00Z'), '1M')).toBe(ms('2024-02-01T00:00:00Z'));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('month-end rollover: March 1st is a new bucket', () => {
|
|
297
|
+
expect(isSameBucket(
|
|
298
|
+
ms('2025-02-28T23:59:59Z'),
|
|
299
|
+
ms('2025-03-01T00:00:00Z'),
|
|
300
|
+
'1M',
|
|
301
|
+
)).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('isSameBucket: Feb 1 and Feb 28 (non-leap)', () => {
|
|
305
|
+
expect(isSameBucket(
|
|
306
|
+
ms('2025-02-01T00:00:00Z'),
|
|
307
|
+
ms('2025-02-28T23:59:59Z'),
|
|
308
|
+
'1M',
|
|
309
|
+
)).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('does NOT use 30-day approximation', () => {
|
|
313
|
+
// Using epoch / 2_592_000_000 (30 days) would shift December 2024 to wrong month.
|
|
314
|
+
// Date.UTC-based implementation must be correct.
|
|
315
|
+
expect(getBucketStart(ms('2024-12-31T23:59:59Z'), '1M')).toBe(ms('2024-12-01T00:00:00Z'));
|
|
316
|
+
expect(getBucketStart(ms('2025-01-01T00:00:00Z'), '1M')).toBe(ms('2025-01-01T00:00:00Z'));
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ─── 4. Live update semantics ─────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
describe('CandleEngine — live update semantics', () => {
|
|
323
|
+
let engine: CandleEngine;
|
|
324
|
+
const TF = '1m';
|
|
325
|
+
|
|
326
|
+
// A round 1-minute boundary in Unix seconds: 2025-03-09 14:00:00 UTC
|
|
327
|
+
const T0 = sec('2025-03-09T14:00:00Z'); // candle 0 open
|
|
328
|
+
const T1 = sec('2025-03-09T14:01:00Z'); // candle 1 open
|
|
329
|
+
|
|
330
|
+
beforeEach(() => {
|
|
331
|
+
engine = new CandleEngine();
|
|
332
|
+
engine.initialize([], TF as any);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('first tick creates the candle', () => {
|
|
336
|
+
const result = engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
337
|
+
expect(result.type).toBe('append');
|
|
338
|
+
const bar = engine.getLastBar()!;
|
|
339
|
+
expect(bar.open).toBe(100);
|
|
340
|
+
expect(bar.high).toBe(110);
|
|
341
|
+
expect(bar.low).toBe(90);
|
|
342
|
+
expect(bar.close).toBe(105);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('same-bucket update: open is immutable', () => {
|
|
346
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
347
|
+
// Second tick with different open — open must stay 100
|
|
348
|
+
engine.applyLiveUpdate(tick(T0 + 30, 200, 215, 195, 210));
|
|
349
|
+
const bar = engine.getLastBar()!;
|
|
350
|
+
expect(bar.open).toBe(100); // unchanged
|
|
351
|
+
expect(bar.close).toBe(210); // updated
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('same-bucket update: high expands upward', () => {
|
|
355
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
356
|
+
engine.applyLiveUpdate(tick(T0 + 30, 100, 150, 90, 145));
|
|
357
|
+
expect(engine.getLastBar()!.high).toBe(150);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('same-bucket update: high never decreases', () => {
|
|
361
|
+
engine.applyLiveUpdate(tick(T0, 100, 150, 90, 145));
|
|
362
|
+
engine.applyLiveUpdate(tick(T0 + 30, 100, 110, 90, 105));
|
|
363
|
+
expect(engine.getLastBar()!.high).toBe(150);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('same-bucket update: low expands downward', () => {
|
|
367
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
368
|
+
engine.applyLiveUpdate(tick(T0 + 30, 100, 110, 70, 80));
|
|
369
|
+
expect(engine.getLastBar()!.low).toBe(70);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('same-bucket update: low never increases', () => {
|
|
373
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 70, 80));
|
|
374
|
+
engine.applyLiveUpdate(tick(T0 + 30, 100, 110, 90, 105));
|
|
375
|
+
expect(engine.getLastBar()!.low).toBe(70);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('same-bucket update: close always updates to the latest tick', () => {
|
|
379
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
380
|
+
engine.applyLiveUpdate(tick(T0 + 30, 100, 110, 90, 108));
|
|
381
|
+
engine.applyLiveUpdate(tick(T0 + 45, 100, 110, 90, 111));
|
|
382
|
+
expect(engine.getLastBar()!.close).toBe(111);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('same-bucket update: bar count stays the same', () => {
|
|
386
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
387
|
+
engine.applyLiveUpdate(tick(T0 + 30, 100, 110, 90, 108));
|
|
388
|
+
expect(engine.getBars().length).toBe(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('new-bucket: creates a new bar and closes the previous', () => {
|
|
392
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
393
|
+
const result = engine.applyLiveUpdate(tick(T1, 106, 120, 100, 115));
|
|
394
|
+
expect(result.type).toBe('append');
|
|
395
|
+
expect(engine.getBars().length).toBe(2);
|
|
396
|
+
|
|
397
|
+
const bars = engine.getBars();
|
|
398
|
+
expect(bars[0]!.isClosed).toBe(true);
|
|
399
|
+
expect(bars[1]!.isClosed).toBe(false);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('new-bucket: previous bar open is unchanged', () => {
|
|
403
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
404
|
+
engine.applyLiveUpdate(tick(T1, 106, 120, 100, 115));
|
|
405
|
+
expect(engine.getBars()[0]!.open).toBe(100);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('out-of-order tick: ignored and bar count unchanged', () => {
|
|
409
|
+
engine.applyLiveUpdate(tick(T1, 106, 120, 100, 115)); // candle 1
|
|
410
|
+
const result = engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105)); // older — ignore
|
|
411
|
+
expect(result.type).toBe('ignore');
|
|
412
|
+
expect(engine.getBars().length).toBe(1);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('gap detection: skipping multiple buckets emits gap result', () => {
|
|
416
|
+
const T5 = sec('2025-03-09T14:05:00Z'); // 5 minutes after T0
|
|
417
|
+
engine.applyLiveUpdate(tick(T0, 100, 110, 90, 105));
|
|
418
|
+
const result = engine.applyLiveUpdate(tick(T5, 106, 120, 100, 115));
|
|
419
|
+
expect(result.type).toBe('gap');
|
|
420
|
+
if (result.type === 'gap') {
|
|
421
|
+
expect(result.missingCount).toBe(4); // T1, T2, T3, T4 missing
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ─── 5. Reconnect tests ───────────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
describe('CandleEngine — reconnect and backfill', () => {
|
|
429
|
+
const TF = '1m';
|
|
430
|
+
const T0 = sec('2025-03-09T14:00:00Z');
|
|
431
|
+
const T1 = sec('2025-03-09T14:01:00Z');
|
|
432
|
+
const T2 = sec('2025-03-09T14:02:00Z');
|
|
433
|
+
const T3 = sec('2025-03-09T14:03:00Z');
|
|
434
|
+
const T4 = sec('2025-03-09T14:04:00Z');
|
|
435
|
+
|
|
436
|
+
it('backfillGap: merges missing bars into existing array', () => {
|
|
437
|
+
const engine = new CandleEngine();
|
|
438
|
+
engine.initialize([tick(T0, 100, 110, 90, 105)], TF as any);
|
|
439
|
+
engine.applyLiveUpdate(tick(T4, 130, 140, 120, 135)); // jumps over T1–T3
|
|
440
|
+
|
|
441
|
+
// Backfill T1–T3 from REST
|
|
442
|
+
engine.backfillGap([
|
|
443
|
+
tick(T1, 106, 115, 100, 112),
|
|
444
|
+
tick(T2, 112, 118, 105, 116),
|
|
445
|
+
tick(T3, 116, 125, 110, 120),
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
const bars = engine.getBars();
|
|
449
|
+
expect(bars.length).toBe(5);
|
|
450
|
+
expect(bars.map(b => b.time)).toEqual([T0, T1, T2, T3, T4]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('backfillGap: historical bars override closed provisional bars', () => {
|
|
454
|
+
const engine = new CandleEngine();
|
|
455
|
+
engine.initialize([
|
|
456
|
+
tick(T0, 100, 110, 90, 105),
|
|
457
|
+
tick(T1, 106, 115, 100, 112), // provisional T1 (currently live/open)
|
|
458
|
+
], TF as any);
|
|
459
|
+
|
|
460
|
+
// Advance to T2 — this closes T1, making it eligible for REST override
|
|
461
|
+
engine.applyLiveUpdate(tick(T2, 112, 118, 105, 116));
|
|
462
|
+
|
|
463
|
+
// REST returns authoritative data for the now-closed T1
|
|
464
|
+
engine.backfillGap([tick(T1, 106, 120, 98, 119)]);
|
|
465
|
+
|
|
466
|
+
const bars = engine.getBars();
|
|
467
|
+
const t1bar = bars.find(b => b.time === T1)!;
|
|
468
|
+
expect(t1bar.high).toBe(120); // authoritative REST value wins for CLOSED bars
|
|
469
|
+
expect(t1bar.low).toBe(98);
|
|
470
|
+
expect(t1bar.close).toBe(119);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('backfillGap: forming candle is preserved after merge', () => {
|
|
474
|
+
const engine = new CandleEngine();
|
|
475
|
+
engine.initialize([tick(T0, 100, 110, 90, 105)], TF as any);
|
|
476
|
+
engine.applyLiveUpdate(tick(T3, 130, 140, 120, 135)); // live bar at T3
|
|
477
|
+
|
|
478
|
+
// Backfill T1–T2 (gap bars before the live bar)
|
|
479
|
+
engine.backfillGap([
|
|
480
|
+
tick(T1, 106, 115, 100, 112),
|
|
481
|
+
tick(T2, 112, 118, 105, 116),
|
|
482
|
+
]);
|
|
483
|
+
|
|
484
|
+
const last = engine.getLastBar()!;
|
|
485
|
+
expect(last.time).toBe(T3);
|
|
486
|
+
expect(last.close).toBe(135); // forming candle values preserved
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('handleReconnect: REST overlap corrects stale candles', () => {
|
|
490
|
+
const engine = new CandleEngine();
|
|
491
|
+
engine.initialize([
|
|
492
|
+
tick(T0, 100, 110, 90, 105),
|
|
493
|
+
tick(T1, 106, 112, 100, 110), // stale — missed a major move during outage
|
|
494
|
+
tick(T2, 110, 115, 105, 113),
|
|
495
|
+
], TF as any);
|
|
496
|
+
|
|
497
|
+
// Reconnect supplies corrected data for T1
|
|
498
|
+
engine.handleReconnect([
|
|
499
|
+
tick(T1, 106, 150, 80, 145), // correct high/low for T1
|
|
500
|
+
tick(T2, 145, 160, 130, 155), // correct T2
|
|
501
|
+
]);
|
|
502
|
+
|
|
503
|
+
const bars = engine.getBars();
|
|
504
|
+
const t1 = bars.find(b => b.time === T1)!;
|
|
505
|
+
expect(t1.high).toBe(150);
|
|
506
|
+
expect(t1.low).toBe(80);
|
|
507
|
+
|
|
508
|
+
const t2 = bars.find(b => b.time === T2)!;
|
|
509
|
+
expect(t2.high).toBe(160);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('handleReconnect: forming candle survives a reconnect', () => {
|
|
513
|
+
const engine = new CandleEngine();
|
|
514
|
+
engine.initialize([tick(T0, 100, 110, 90, 105)], TF as any);
|
|
515
|
+
engine.applyLiveUpdate(tick(T2, 112, 120, 108, 118)); // forming candle at T2
|
|
516
|
+
|
|
517
|
+
// The overlap window includes T0–T1 history but not yet T2
|
|
518
|
+
engine.handleReconnect([
|
|
519
|
+
tick(T0, 100, 115, 88, 108), // slightly different authoritative T0
|
|
520
|
+
tick(T1, 108, 120, 104, 116),
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
// T2 (live bar) should survive untouched
|
|
524
|
+
const last = engine.getLastBar()!;
|
|
525
|
+
expect(last.time).toBe(T2);
|
|
526
|
+
expect(last.close).toBe(118);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('handleReconnect: gaps between reconnect bars are handled', () => {
|
|
530
|
+
const engine = new CandleEngine();
|
|
531
|
+
engine.initialize([
|
|
532
|
+
tick(T0, 100, 110, 90, 105),
|
|
533
|
+
], TF as any);
|
|
534
|
+
|
|
535
|
+
engine.handleReconnect([
|
|
536
|
+
tick(T0, 100, 115, 88, 112),
|
|
537
|
+
tick(T2, 112, 120, 108, 118), // T1 is missing from the REST window
|
|
538
|
+
tick(T4, 120, 130, 115, 128),
|
|
539
|
+
]);
|
|
540
|
+
|
|
541
|
+
// Result must be sorted and include all available bars
|
|
542
|
+
const bars = engine.getBars();
|
|
543
|
+
const times = bars.map(b => b.time);
|
|
544
|
+
expect(times).toEqual(times.slice().sort((a, b) => a - b));
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// ─── 6. normalizeTimestamp edge cases ────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
describe('normalizeTimestamp — boundary cases', () => {
|
|
551
|
+
it('Unix seconds (≤ 1e12) are multiplied by 1000', () => {
|
|
552
|
+
expect(normalizeTimestamp(1_741_528_662)).toBe(1_741_528_662_000);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('Unix milliseconds (> 1e12) are returned as-is', () => {
|
|
556
|
+
expect(normalizeTimestamp(1_741_528_662_000)).toBe(1_741_528_662_000);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('numeric string in seconds is treated as seconds', () => {
|
|
560
|
+
expect(normalizeTimestamp('1741528662')).toBe(1_741_528_662_000);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('numeric string in ms is returned as-is', () => {
|
|
564
|
+
expect(normalizeTimestamp('1741528662000')).toBe(1_741_528_662_000);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('Date object returns its getTime()', () => {
|
|
568
|
+
const d = new Date('2025-03-09T14:37:42Z');
|
|
569
|
+
expect(normalizeTimestamp(d)).toBe(d.getTime());
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('throws RangeError on NaN', () => {
|
|
573
|
+
expect(() => normalizeTimestamp(NaN)).toThrow(RangeError);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('throws RangeError on Infinity', () => {
|
|
577
|
+
expect(() => normalizeTimestamp(Infinity)).toThrow(RangeError);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('throws RangeError on invalid date string', () => {
|
|
581
|
+
expect(() => normalizeTimestamp('not-a-date')).toThrow(RangeError);
|
|
582
|
+
});
|
|
583
|
+
});
|