1- import { assertEquals } from '@std/assert'
2- import { type CandleData , Transform } from '@app/index.ts'
1+ import { assertEquals , assertThrows } from '@std/assert'
2+ import { type CandleData , Time , Transform } from '@app/index.ts'
33
44function getTs ( d : number , h : number , m : number ) : number {
55 return Date . UTC ( 2025 , 0 , d , h , m )
66}
77
8+ Deno . test ( 'alignTime: aligns to bucket start' , ( ) => {
9+ const ts = getTs ( 1 , 10 , 17 )
10+ const bucket = Time . alignTime ( ts , Time . msPerHour , 23 )
11+ assertEquals ( new Date ( bucket ) . toUTCString ( ) , 'Wed, 01 Jan 2025 10:00:00 GMT' )
12+ } )
13+
14+ Deno . test ( 'alignTime: anchor with minute' , ( ) => {
15+ const ts = getTs ( 1 , 10 , 45 )
16+ const bucket = Time . alignTime ( ts , Time . msPerHour , 10 , 30 )
17+ assertEquals ( new Date ( bucket ) . toUTCString ( ) , 'Wed, 01 Jan 2025 10:30:00 GMT' )
18+ } )
19+
20+ Deno . test ( 'alignTime: timestamp on bucket boundary unchanged' , ( ) => {
21+ const exact = getTs ( 1 , 10 , 0 )
22+ const bucket = Time . alignTime ( exact , Time . msPerHour , 23 )
23+ assertEquals ( bucket , exact )
24+ } )
25+
26+ Deno . test ( 'Candle without volume gets volume 0 in output' , ( ) => {
27+ const data : CandleData [ ] = [ { time : getTs ( 1 , 10 , 0 ) , open : 1 , high : 1 , low : 1 , close : 1 } ]
28+ const tf = Transform . from ( data ) . to ( '1h' )
29+ assertEquals ( tf [ 0 ] ?. volume , 0 )
30+ } )
31+
32+ Deno . test ( 'defaultAnchorOffset equals 23h in ms' , ( ) => {
33+ assertEquals ( Time . defaultAnchorOffset , 23 * Time . msPerHour )
34+ } )
35+
36+ Deno . test ( 'Empty input returns empty array' , ( ) => {
37+ assertEquals ( Transform . from ( [ ] ) . to ( '1h' ) , [ ] )
38+ assertEquals ( Transform . execute ( [ ] , '4h' ) , [ ] )
39+ } )
40+
41+ Deno . test ( 'parseTimeframe: invalid format throws' , ( ) => {
42+ assertThrows ( ( ) => Time . parseTimeframe ( '' ) , Error , 'Invalid timeframe format' )
43+ assertThrows ( ( ) => Time . parseTimeframe ( '1' ) , Error , 'Invalid timeframe format' )
44+ assertThrows ( ( ) => Time . parseTimeframe ( '1x' ) , Error , 'Invalid timeframe format' )
45+ assertThrows ( ( ) => Time . parseTimeframe ( 'm' ) , Error , 'Invalid timeframe format' )
46+ assertThrows ( ( ) => Time . parseTimeframe ( '1y' ) , Error , 'Invalid timeframe format' )
47+ } )
48+
49+ Deno . test ( 'parseTimeframe: valid m, h, d, w, M' , ( ) => {
50+ assertEquals ( Time . parseTimeframe ( '1m' ) , Time . msPerMinute )
51+ assertEquals ( Time . parseTimeframe ( '15m' ) , 15 * Time . msPerMinute )
52+ assertEquals ( Time . parseTimeframe ( '1h' ) , Time . msPerHour )
53+ assertEquals ( Time . parseTimeframe ( '4h' ) , 4 * Time . msPerHour )
54+ assertEquals ( Time . parseTimeframe ( '1d' ) , Time . msPerDay )
55+ assertEquals ( Time . parseTimeframe ( '2d' ) , 2 * Time . msPerDay )
56+ assertEquals ( Time . parseTimeframe ( '1w' ) , Time . msPerWeek )
57+ assertEquals ( Time . parseTimeframe ( '2w' ) , 2 * Time . msPerWeek )
58+ assertEquals ( Time . parseTimeframe ( '1M' ) , Time . msPerMonth )
59+ assertEquals ( Time . parseTimeframe ( '2M' ) , 2 * Time . msPerMonth )
60+ } )
61+
62+ Deno . test ( 'Single candle returns single aggregated candle' , ( ) => {
63+ const data : CandleData [ ] = [ { time : getTs ( 1 , 10 , 5 ) , open : 100 , high : 102 , low : 99 , close : 101 } ]
64+ const tf = Transform . from ( data ) . to ( '1h' )
65+ assertEquals ( tf . length , 1 )
66+ const [ c ] = tf
67+ if ( ! c ) {
68+ throw new Error ( 'Missing candle' )
69+ }
70+ assertEquals ( c . open , 100 )
71+ assertEquals ( c . high , 102 )
72+ assertEquals ( c . low , 99 )
73+ assertEquals ( c . close , 101 )
74+ assertEquals ( new Date ( c . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 10:00:00 GMT' )
75+ } )
76+
77+ Deno . test ( 'Transform.anchor: invalid hour throws' , ( ) => {
78+ const data : CandleData [ ] = [ { time : getTs ( 1 , 0 , 0 ) , open : 1 , high : 1 , low : 1 , close : 1 } ]
79+ assertThrows ( ( ) => Transform . from ( data ) . anchor ( - 1 ) . to ( '1h' ) , Error , '0 and 23' )
80+ assertThrows ( ( ) => Transform . from ( data ) . anchor ( 24 ) . to ( '1h' ) , Error , '0 and 23' )
81+ } )
82+
83+ Deno . test ( 'Transform.anchor: invalid minute throws' , ( ) => {
84+ const data : CandleData [ ] = [ { time : getTs ( 1 , 0 , 0 ) , open : 1 , high : 1 , low : 1 , close : 1 } ]
85+ assertThrows ( ( ) => Transform . from ( data ) . anchor ( 12 , - 1 ) . to ( '1h' ) , Error , '0 and 59' )
86+ assertThrows ( ( ) => Transform . from ( data ) . anchor ( 12 , 60 ) . to ( '1h' ) , Error , '0 and 59' )
87+ } )
88+
89+ Deno . test ( 'Transform.execute with explicit anchor matches fluent API' , ( ) => {
90+ const data : CandleData [ ] = [
91+ { time : getTs ( 1 , 10 , 10 ) , open : 1 , high : 2 , low : 1 , close : 2 } ,
92+ { time : getTs ( 1 , 11 , 10 ) , open : 2 , high : 3 , low : 2 , close : 3 }
93+ ]
94+ const fromFluent = Transform . from ( data ) . anchor ( 23 ) . to ( '1h' )
95+ const fromStatic = Transform . execute ( data , '1h' , 23 )
96+ assertEquals ( fromFluent . length , fromStatic . length )
97+ assertEquals ( fromFluent [ 0 ] ?. time , fromStatic [ 0 ] ?. time )
98+ assertEquals ( fromFluent [ 1 ] ?. time , fromStatic [ 1 ] ?. time )
99+ } )
100+
101+ Deno . test ( 'Transform.to: invalid timeframe throws' , ( ) => {
102+ const data : CandleData [ ] = [ { time : getTs ( 1 , 0 , 0 ) , open : 1 , high : 1 , low : 1 , close : 1 } ]
103+ assertThrows ( ( ) => Transform . from ( data ) . to ( '2y' ) , Error , 'Invalid timeframe format' )
104+ } )
105+
8106Deno . test ( 'Transformation: 1m -> 1h (Standard 00:00 Anchor)' , ( ) => {
9107 const data : CandleData [ ] = [
10108 { time : getTs ( 1 , 10 , 15 ) , open : 1 , high : 2 , low : 1 , close : 1.5 } ,
@@ -23,6 +121,22 @@ Deno.test('Transformation: 1m -> 1h (Standard 00:00 Anchor)', () => {
23121 assertEquals ( new Date ( c2 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 11:00:00 GMT' )
24122} )
25123
124+ Deno . test ( 'Transformation: 1m -> 4h (Anchor 00:00 UTC)' , ( ) => {
125+ const data : CandleData [ ] = [
126+ { time : getTs ( 1 , 2 , 59 ) , open : 1 , high : 1 , low : 1 , close : 1 } ,
127+ { time : getTs ( 1 , 3 , 1 ) , open : 2 , high : 2 , low : 2 , close : 2 } ,
128+ { time : getTs ( 1 , 4 , 1 ) , open : 3 , high : 3 , low : 3 , close : 3 }
129+ ]
130+ const tf = Transform . from ( data ) . anchor ( 0 ) . to ( '4h' )
131+ assertEquals ( tf . length , 2 )
132+ const [ c1 , c2 ] = tf
133+ if ( ! c1 || ! c2 ) {
134+ throw new Error ( 'Missing expected candles' )
135+ }
136+ assertEquals ( new Date ( c1 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 00:00:00 GMT' )
137+ assertEquals ( new Date ( c2 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 04:00:00 GMT' )
138+ } )
139+
26140Deno . test ( 'Transformation: 1m -> 4h (Anchor 23:00 UTC)' , ( ) => {
27141 const data : CandleData [ ] = [
28142 { time : getTs ( 1 , 2 , 59 ) , open : 1 , high : 1 , low : 1 , close : 1 } ,
@@ -38,20 +152,22 @@ Deno.test('Transformation: 1m -> 4h (Anchor 23:00 UTC)', () => {
38152 assertEquals ( new Date ( c2 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 03:00:00 GMT' )
39153} )
40154
41- Deno . test ( 'Transformation: 1m -> 4h (Anchor 00:00 UTC)' , ( ) => {
155+ Deno . test ( 'Transformation: Anchor with hour and minute (23:30 UTC)' , ( ) => {
42156 const data : CandleData [ ] = [
43- { time : getTs ( 1 , 2 , 59 ) , open : 1 , high : 1 , low : 1 , close : 1 } ,
44- { time : getTs ( 1 , 3 , 1 ) , open : 2 , high : 2 , low : 2 , close : 2 } ,
45- { time : getTs ( 1 , 4 , 1 ) , open : 3 , high : 3 , low : 3 , close : 3 }
157+ { time : getTs ( 1 , 23 , 35 ) , open : 1 , high : 1 , low : 1 , close : 1 } ,
158+ { time : getTs ( 1 , 23 , 50 ) , open : 2 , high : 2 , low : 2 , close : 2 } ,
159+ { time : getTs ( 2 , 0 , 35 ) , open : 3 , high : 3 , low : 3 , close : 3 }
46160 ]
47- const tf = Transform . from ( data ) . anchor ( 0 ) . to ( '4h ' )
161+ const tf = Transform . from ( data ) . anchor ( 23 , 30 ) . to ( '1h ' )
48162 assertEquals ( tf . length , 2 )
49163 const [ c1 , c2 ] = tf
50164 if ( ! c1 || ! c2 ) {
51165 throw new Error ( 'Missing expected candles' )
52166 }
53- assertEquals ( new Date ( c1 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 00:00:00 GMT' )
54- assertEquals ( new Date ( c2 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 04:00:00 GMT' )
167+ assertEquals ( new Date ( c1 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 23:30:00 GMT' )
168+ assertEquals ( c1 . close , 2 )
169+ assertEquals ( new Date ( c2 . time ) . toUTCString ( ) , 'Thu, 02 Jan 2025 00:30:00 GMT' )
170+ assertEquals ( c2 . close , 3 )
55171} )
56172
57173Deno . test ( 'Transformation: Custom Timeframes (10m, 15m, 45m)' , ( ) => {
@@ -76,3 +192,31 @@ Deno.test('Transformation: Custom Timeframes (10m, 15m, 45m)', () => {
76192 assertEquals ( new Date ( f1 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 09:30:00 GMT' )
77193 assertEquals ( new Date ( f2 . time ) . toUTCString ( ) , 'Wed, 01 Jan 2025 10:15:00 GMT' )
78194} )
195+
196+ Deno . test ( 'Unsorted input produces same result as sorted' , ( ) => {
197+ const sorted : CandleData [ ] = [
198+ { time : getTs ( 1 , 10 , 5 ) , open : 1 , high : 2 , low : 1 , close : 1.5 } ,
199+ { time : getTs ( 1 , 10 , 35 ) , open : 1.5 , high : 2.5 , low : 1.2 , close : 2 }
200+ ]
201+ const unsorted : CandleData [ ] = [ sorted [ 1 ] ! , sorted [ 0 ] ! ]
202+ const outSorted = Transform . from ( sorted ) . to ( '1h' )
203+ const outUnsorted = Transform . from ( unsorted ) . to ( '1h' )
204+ assertEquals ( outSorted . length , outUnsorted . length )
205+ assertEquals ( outSorted [ 0 ] ?. time , outUnsorted [ 0 ] ?. time )
206+ assertEquals ( outSorted [ 0 ] ?. high , outUnsorted [ 0 ] ?. high )
207+ assertEquals ( outSorted [ 0 ] ?. close , outUnsorted [ 0 ] ?. close )
208+ } )
209+
210+ Deno . test ( 'Volume aggregation in same bucket' , ( ) => {
211+ const data : CandleData [ ] = [
212+ { time : getTs ( 1 , 10 , 5 ) , open : 1 , high : 2 , low : 1 , close : 1.5 , volume : 10 } ,
213+ { time : getTs ( 1 , 10 , 25 ) , open : 1.5 , high : 2.5 , low : 1.2 , close : 2 , volume : 20 }
214+ ]
215+ const tf = Transform . from ( data ) . to ( '1h' )
216+ assertEquals ( tf . length , 1 )
217+ const [ c ] = tf
218+ if ( ! c ) {
219+ throw new Error ( 'Missing candle' )
220+ }
221+ assertEquals ( c . volume , 30 )
222+ } )
0 commit comments