Skip to content

Commit 371c3ff

Browse files
committed
docs(transform): API reference and expand Transform tests 💫
- Update README features, API reference, and note on 1w/1M - Add Transform tests for alignTime, anchor validation, volume, unsorted input
1 parent 3aed5ce commit 371c3ff

2 files changed

Lines changed: 222 additions & 29 deletions

File tree

README.md

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
# Candle Transform [![Module type: Deno/ESM](https://img.shields.io/badge/module%20type-deno%2Fesm-brightgreen)](https://github.com/NeaByteLab/Candle-Transform) [![npm version](https://img.shields.io/npm/v/@neabyte/candle-transform.svg)](https://www.npmjs.org/package/@neabyte/candle-transform) [![JSR](https://jsr.io/badges/@neabyte/candle-transform)](https://jsr.io/@neabyte/candle-transform) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
1+
<div align="center">
2+
3+
# Candle Transform
24

35
High-precision OHLC transformation with strict anchor time alignment.
46

7+
[![Module type: Deno/ESM](https://img.shields.io/badge/module%20type-deno%2Fesm-brightgreen)](https://github.com/NeaByteLab/Candle-Transform) [![npm version](https://img.shields.io/npm/v/@neabyte/candle-transform.svg)](https://www.npmjs.org/package/@neabyte/candle-transform) [![JSR](https://jsr.io/badges/@neabyte/candle-transform)](https://jsr.io/@neabyte/candle-transform) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8+
9+
</div>
10+
511
## Features
612

7-
- **Strict Anchor Alignment**: Ensures candles align with specific sessions (e.g., 4h candle starting at 23:00).
8-
- **High Performance**: Batch processing optimized for thousands of candles.
9-
- **Flexible Timeframes**: Supports `m`, `h`, `d` inputs.
13+
- **Strict Anchor Alignment**: Ensures candles align with specific sessions (e.g., 4h at 23:00 or 23:30 UTC).
14+
- **High Performance**: Batch processing optimized for thousands of candles at once.
15+
- **Flexible Timeframes**: Supports `m`, `h`, `d`, `w`, `M` (e.g. `15m`, `4h`, `1d`, `1w`, `1M`).
1016

1117
## Installation
1218

@@ -41,40 +47,83 @@ console.log(h4) // Output: [ { time: 1704063600000, open: 1, high: 2, low: 0.5,
4147

4248
// Convert to 1-day chart with custom anchor (e.g., 00:00 UTC)
4349
const daily = Transform.from(data).anchor(0).to('1d')
44-
console.log(daily) // Output: [ { time: 1704063600000, open: 1, high: 2, low: 0.5, close: 1.5, ... }, ... ]
50+
51+
// Anchor with hour and minute (e.g., 23:30 UTC)
52+
const session = Transform.from(data).anchor(23, 30).to('4h')
4553
```
4654

4755
## API Reference
4856

49-
### `Transform.from(data)`
57+
### Transform.from
5058

51-
Creates a transformation instance from source data.
59+
```typescript
60+
Transform.from(data)
61+
```
5262

53-
**Parameters:**
63+
- `data` `<CandleData[]>`: Array of source OHLC candles
64+
- Returns: `Transform`
65+
- Description: Creates a transformation instance for fluent chaining.
5466

55-
- `data: CandleData[]` - Array of source candles
67+
### Transform.prototype.anchor
5668

57-
### `.anchor(hour)`
69+
```typescript
70+
transform.anchor(hour, minute)
71+
```
5872

59-
Sets the anchor hour in UTC for time alignment.
73+
- `hour` `<number>`: Anchor hour in UTC (0–23). Defaults to `23`.
74+
- `minute` `<number>`: (Optional) Anchor minute (0–59). Defaults to `0`.
75+
- Returns: `this`
76+
- Description: Sets the anchor time for bucket alignment (e.g. 23:30 UTC).
6077

61-
**Parameters:**
78+
### Transform.prototype.to
6279

63-
- `hour: number` - Hour (0-23). Default is `23` (23:00 UTC Market Open)
80+
```typescript
81+
transform.to(timeframe)
82+
```
6483

65-
### `.to(timeframe)`
84+
- `timeframe` `<string>`: Target timeframe (e.g. `'15m'`, `'4h'`, `'1d'`, `'1w'`, `'1M'`).
85+
- Returns: `CandleData[]`
86+
- Description: Runs the transformation and returns aggregated candles.
6687

67-
Executes the transformation.
88+
### Transform.execute
6889

69-
**Parameters:**
90+
```typescript
91+
Transform.execute(candles, timeframe, anchorHour, anchorMinute)
92+
```
93+
94+
- `candles` `<CandleData[]>`: Source candle array
95+
- `timeframe` `<string>`: Target timeframe (e.g. `'4h'`, `'1d'`)
96+
- `anchorHour` `<number>`: (Optional) Anchor hour (0–23). Defaults to `23`.
97+
- `anchorMinute` `<number>`: (Optional) Anchor minute (0–59). Defaults to `0`.
98+
- Returns: `CandleData[]`
99+
- Description: Runs batch transformation without a fluent instance.
100+
101+
### Time.alignTime
102+
103+
```typescript
104+
Time.alignTime(timestamp, intervalMs, anchorHour, anchorMinute)
105+
```
106+
107+
- `timestamp` `<number>`: Input time in milliseconds
108+
- `intervalMs` `<number>`: Bucket interval in milliseconds
109+
- `anchorHour` `<number>`: (Optional) Anchor hour (0–23). Defaults to `23`.
110+
- `anchorMinute` `<number>`: (Optional) Anchor minute (0–59). Defaults to `0`.
111+
- Returns: `number`
112+
- Description: Aligns timestamp to the bucket open time on the anchor grid.
113+
114+
### Time.parseTimeframe
70115

71-
- `timeframe: TimeframeStr` - Target timeframe (e.g., `'15m'`, `'4h'`, `'1d'`)
116+
```typescript
117+
Time.parseTimeframe(tf)
118+
```
72119

73-
**Returns:** `CandleData[]` - Transformed candles
120+
- `tf` `<string>`: Timeframe string (e.g. `'15m'`, `'4h'`, `'1d'`, `'1w'`, `'1M'`)
121+
- Returns: `number`
122+
- Description: Parses timeframe string to duration in milliseconds.
74123

75-
## Limitations
124+
## Note
76125

77-
Currently supports timeframes up to `1d` (Daily). Weekly (`1w`) and Monthly (`1M`) are not yet supported.
126+
- `1w` = 7 days; `1M` = 30-day period (fixed length, not calendar month).
78127

79128
## License
80129

tests/Transform.test.ts

Lines changed: 153 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,108 @@
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

44
function 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+
8106
Deno.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+
26140
Deno.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

57173
Deno.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

Comments
 (0)