22 * Indicator Calculator unit tests
33 *
44 * 覆盖:四则运算、运算符优先级、数据访问、统计函数、技术指标、
5- * 数组索引、嵌套表达式、精度控制、错误处理。
5+ * 数组索引、嵌套表达式、精度控制、错误处理、数据溯源(dataRange) 。
66 */
77import { describe , it , expect } from 'vitest'
88import { IndicatorCalculator } from './calculator'
9- import type { IndicatorContext , OhlcvData } from './types'
9+ import type { IndicatorContext , OhlcvData , TrackedValues } from './types'
1010
1111// Mock: 50 根日线,收盘价 100~149,volume 第 48 根为 null 测边界
1212const mockData : OhlcvData [ ] = Array . from ( { length : 50 } , ( _ , i ) => ( {
@@ -20,12 +20,18 @@ const mockData: OhlcvData[] = Array.from({ length: 50 }, (_, i) => ({
2020} ) )
2121
2222const mockContext : IndicatorContext = {
23- getHistoricalData : async ( _symbol : string , _interval : string ) => {
24- return mockData
25- } ,
23+ getHistoricalData : async ( _symbol : string , _interval : string ) => ( {
24+ data : mockData ,
25+ meta : {
26+ symbol : _symbol ,
27+ from : mockData [ 0 ] . date ,
28+ to : mockData [ mockData . length - 1 ] . date ,
29+ bars : mockData . length ,
30+ } ,
31+ } ) ,
2632}
2733
28- function calc ( formula : string , precision ?: number ) {
34+ async function calc ( formula : string , precision ?: number ) {
2935 const calculator = new IndicatorCalculator ( mockContext )
3036 return calculator . calculate ( formula , precision )
3137}
@@ -34,48 +40,47 @@ function calc(formula: string, precision?: number) {
3440
3541describe ( 'arithmetic' , ( ) => {
3642 it ( 'addition' , async ( ) => {
37- expect ( await calc ( '2 + 3' ) ) . toBe ( 5 )
43+ expect ( ( await calc ( '2 + 3' ) ) . value ) . toBe ( 5 )
3844 } )
3945
4046 it ( 'subtraction' , async ( ) => {
41- expect ( await calc ( '10 - 4' ) ) . toBe ( 6 )
47+ expect ( ( await calc ( '10 - 4' ) ) . value ) . toBe ( 6 )
4248 } )
4349
4450 it ( 'multiplication' , async ( ) => {
45- expect ( await calc ( '3 * 7' ) ) . toBe ( 21 )
51+ expect ( ( await calc ( '3 * 7' ) ) . value ) . toBe ( 21 )
4652 } )
4753
4854 it ( 'division' , async ( ) => {
49- expect ( await calc ( '15 / 4' ) ) . toBe ( 3.75 )
55+ expect ( ( await calc ( '15 / 4' ) ) . value ) . toBe ( 3.75 )
5056 } )
5157
5258 it ( 'operator precedence: * before +' , async ( ) => {
53- expect ( await calc ( '2 + 3 * 4' ) ) . toBe ( 14 )
59+ expect ( ( await calc ( '2 + 3 * 4' ) ) . value ) . toBe ( 14 )
5460 } )
5561
5662 it ( 'operator precedence: / before -' , async ( ) => {
57- expect ( await calc ( '10 - 6 / 2' ) ) . toBe ( 7 )
63+ expect ( ( await calc ( '10 - 6 / 2' ) ) . value ) . toBe ( 7 )
5864 } )
5965
6066 it ( 'parentheses override precedence' , async ( ) => {
61- expect ( await calc ( '(2 + 3) * 4' ) ) . toBe ( 20 )
67+ expect ( ( await calc ( '(2 + 3) * 4' ) ) . value ) . toBe ( 20 )
6268 } )
6369
6470 it ( 'nested parentheses' , async ( ) => {
65- expect ( await calc ( '((1 + 2) * (3 + 4))' ) ) . toBe ( 21 )
71+ expect ( ( await calc ( '((1 + 2) * (3 + 4))' ) ) . value ) . toBe ( 21 )
6672 } )
6773
6874 it ( 'negative numbers' , async ( ) => {
69- expect ( await calc ( '-5 + 3' ) ) . toBe ( - 2 )
75+ expect ( ( await calc ( '-5 + 3' ) ) . value ) . toBe ( - 2 )
7076 } )
7177
7278 it ( 'decimal numbers' , async ( ) => {
73- expect ( await calc ( '1.5 * 2.0' ) ) . toBe ( 3 )
79+ expect ( ( await calc ( '1.5 * 2.0' ) ) . value ) . toBe ( 3 )
7480 } )
7581
7682 it ( 'chained operations left to right' , async ( ) => {
77- // 10 - 3 - 2 = 5 (left-associative)
78- expect ( await calc ( '10 - 3 - 2' ) ) . toBe ( 5 )
83+ expect ( ( await calc ( '10 - 3 - 2' ) ) . value ) . toBe ( 5 )
7984 } )
8085
8186 it ( 'division by zero throws' , async ( ) => {
@@ -87,35 +92,34 @@ describe('arithmetic', () => {
8792// mockData 返回全量 50 根:close 100..149, high 102..151, low 99..148, open 100..149
8893
8994describe ( 'data access' , ( ) => {
90- it ( 'CLOSE returns all 50 bars' , async ( ) => {
91- const result = ( await calc ( "CLOSE('AAPL', '1d')" ) ) as number [ ]
95+ it ( 'CLOSE returns TrackedValues with 50 bars' , async ( ) => {
96+ const result = ( await calc ( "CLOSE('AAPL', '1d')" ) ) . value as number [ ]
9297 expect ( Array . isArray ( result ) ) . toBe ( true )
9398 expect ( result . length ) . toBe ( 50 )
9499 expect ( result [ 0 ] ) . toBe ( 100 )
95100 expect ( result [ 49 ] ) . toBe ( 149 )
96101 } )
97102
98103 it ( 'HIGH returns correct values' , async ( ) => {
99- const result = ( await calc ( "HIGH('AAPL', '1d')" ) ) as number [ ]
104+ const result = ( await calc ( "HIGH('AAPL', '1d')" ) ) . value as number [ ]
100105 expect ( result [ 0 ] ) . toBe ( 102 )
101106 expect ( result [ 49 ] ) . toBe ( 151 )
102107 } )
103108
104109 it ( 'LOW returns correct values' , async ( ) => {
105- const result = ( await calc ( "LOW('AAPL', '1d')" ) ) as number [ ]
110+ const result = ( await calc ( "LOW('AAPL', '1d')" ) ) . value as number [ ]
106111 expect ( result [ 0 ] ) . toBe ( 99 )
107112 expect ( result [ 49 ] ) . toBe ( 148 )
108113 } )
109114
110115 it ( 'OPEN returns correct values' , async ( ) => {
111- const result = ( await calc ( "OPEN('AAPL', '1d')" ) ) as number [ ]
116+ const result = ( await calc ( "OPEN('AAPL', '1d')" ) ) . value as number [ ]
112117 expect ( result [ 0 ] ) . toBe ( 100 )
113118 expect ( result [ 49 ] ) . toBe ( 149 )
114119 } )
115120
116121 it ( 'VOLUME handles null as 0' , async ( ) => {
117- // mockData[48].volume = null, mockData[49].volume = 1490
118- const result = ( await calc ( "VOLUME('AAPL', '1d')" ) ) as number [ ]
122+ const result = ( await calc ( "VOLUME('AAPL', '1d')" ) ) . value as number [ ]
119123 expect ( result [ 48 ] ) . toBe ( 0 )
120124 expect ( result [ 49 ] ) . toBe ( 1490 )
121125 } )
@@ -125,15 +129,15 @@ describe('data access', () => {
125129
126130describe ( 'array access' , ( ) => {
127131 it ( 'positive index' , async ( ) => {
128- expect ( await calc ( "CLOSE('AAPL', '1d')[0]" ) ) . toBe ( 100 )
132+ expect ( ( await calc ( "CLOSE('AAPL', '1d')[0]" ) ) . value ) . toBe ( 100 )
129133 } )
130134
131135 it ( 'negative index (-1 = last)' , async ( ) => {
132- expect ( await calc ( "CLOSE('AAPL', '1d')[-1]" ) ) . toBe ( 149 )
136+ expect ( ( await calc ( "CLOSE('AAPL', '1d')[-1]" ) ) . value ) . toBe ( 149 )
133137 } )
134138
135139 it ( 'negative index (-2 = second to last)' , async ( ) => {
136- expect ( await calc ( "CLOSE('AAPL', '1d')[-2]" ) ) . toBe ( 148 )
140+ expect ( ( await calc ( "CLOSE('AAPL', '1d')[-2]" ) ) . value ) . toBe ( 148 )
137141 } )
138142
139143 it ( 'out of bounds throws' , async ( ) => {
@@ -146,42 +150,37 @@ describe('array access', () => {
146150
147151describe ( 'statistics' , ( ) => {
148152 it ( 'SMA' , async ( ) => {
149- // SMA(10) of 50 bars: average of last 10 = (140+...+149)/10 = 144.5
150- expect ( await calc ( "SMA(CLOSE('AAPL', '1d'), 10)" ) ) . toBe ( 144.5 )
153+ expect ( ( await calc ( "SMA(CLOSE('AAPL', '1d'), 10)" ) ) . value ) . toBe ( 144.5 )
151154 } )
152155
153156 it ( 'EMA' , async ( ) => {
154- const result = await calc ( "EMA(CLOSE('AAPL', '1d'), 10)" )
157+ const result = ( await calc ( "EMA(CLOSE('AAPL', '1d'), 10)" ) ) . value as number
155158 expect ( typeof result ) . toBe ( 'number' )
156159 expect ( result ) . toBeGreaterThan ( 140 )
157160 } )
158161
159162 it ( 'STDEV' , async ( ) => {
160- // stdev of 100..149 ≈ 14.43
161- const result = await calc ( "STDEV(CLOSE('AAPL', '1d'))" )
163+ const result = ( await calc ( "STDEV(CLOSE('AAPL', '1d'))" ) ) . value
162164 expect ( result ) . toBeCloseTo ( 14.43 , 1 )
163165 } )
164166
165167 it ( 'MAX' , async ( ) => {
166- expect ( await calc ( "MAX(CLOSE('AAPL', '1d'))" ) ) . toBe ( 149 )
168+ expect ( ( await calc ( "MAX(CLOSE('AAPL', '1d'))" ) ) . value ) . toBe ( 149 )
167169 } )
168170
169171 it ( 'MIN' , async ( ) => {
170- expect ( await calc ( "MIN(CLOSE('AAPL', '1d'))" ) ) . toBe ( 100 )
172+ expect ( ( await calc ( "MIN(CLOSE('AAPL', '1d'))" ) ) . value ) . toBe ( 100 )
171173 } )
172174
173175 it ( 'SUM' , async ( ) => {
174- // 100+101+...+149 = 50 * (100+149)/2 = 6225
175- expect ( await calc ( "SUM(CLOSE('AAPL', '1d'))" ) ) . toBe ( 6225 )
176+ expect ( ( await calc ( "SUM(CLOSE('AAPL', '1d'))" ) ) . value ) . toBe ( 6225 )
176177 } )
177178
178179 it ( 'AVERAGE' , async ( ) => {
179- // (100+...+149)/50 = 124.5
180- expect ( await calc ( "AVERAGE(CLOSE('AAPL', '1d'))" ) ) . toBe ( 124.5 )
180+ expect ( ( await calc ( "AVERAGE(CLOSE('AAPL', '1d'))" ) ) . value ) . toBe ( 124.5 )
181181 } )
182182
183183 it ( 'SMA insufficient data throws' , async ( ) => {
184- // 50 bars but SMA(100) needs 100
185184 await expect ( calc ( "SMA(CLOSE('AAPL', '1d'), 100)" ) ) . rejects . toThrow ( 'at least 100' )
186185 } )
187186} )
@@ -190,15 +189,14 @@ describe('statistics', () => {
190189
191190describe ( 'technical indicators' , ( ) => {
192191 it ( 'RSI returns 0-100, trending up → high RSI' , async ( ) => {
193- const result = ( await calc ( "RSI(CLOSE('AAPL', '1d'), 14)" ) ) as number
192+ const result = ( await calc ( "RSI(CLOSE('AAPL', '1d'), 14)" ) ) . value as number
194193 expect ( result ) . toBeGreaterThanOrEqual ( 0 )
195194 expect ( result ) . toBeLessThanOrEqual ( 100 )
196- // 连续上涨,RSI 应接近 100
197195 expect ( result ) . toBeGreaterThan ( 90 )
198196 } )
199197
200198 it ( 'BBANDS returns { upper, middle, lower }' , async ( ) => {
201- const result = ( await calc ( "BBANDS(CLOSE('AAPL', '1d'), 20, 2)" ) ) as Record < string , number >
199+ const result = ( await calc ( "BBANDS(CLOSE('AAPL', '1d'), 20, 2)" ) ) . value as Record < string , number >
202200 expect ( result ) . toHaveProperty ( 'upper' )
203201 expect ( result ) . toHaveProperty ( 'middle' )
204202 expect ( result ) . toHaveProperty ( 'lower' )
@@ -207,15 +205,15 @@ describe('technical indicators', () => {
207205 } )
208206
209207 it ( 'MACD returns { macd, signal, histogram }' , async ( ) => {
210- const result = ( await calc ( "MACD(CLOSE('AAPL', '1d'), 12, 26, 9)" ) ) as Record < string , number >
208+ const result = ( await calc ( "MACD(CLOSE('AAPL', '1d'), 12, 26, 9)" ) ) . value as Record < string , number >
211209 expect ( result ) . toHaveProperty ( 'macd' )
212210 expect ( result ) . toHaveProperty ( 'signal' )
213211 expect ( result ) . toHaveProperty ( 'histogram' )
214212 expect ( typeof result . macd ) . toBe ( 'number' )
215213 } )
216214
217215 it ( 'ATR returns positive number' , async ( ) => {
218- const result = ( await calc ( "ATR(HIGH('AAPL', '1d'), LOW('AAPL', '1d'), CLOSE('AAPL', '1d'), 14)" ) ) as number
216+ const result = ( await calc ( "ATR(HIGH('AAPL', '1d'), LOW('AAPL', '1d'), CLOSE('AAPL', '1d'), 14)" ) ) . value as number
219217 expect ( typeof result ) . toBe ( 'number' )
220218 expect ( result ) . toBeGreaterThan ( 0 )
221219 } )
@@ -225,22 +223,19 @@ describe('technical indicators', () => {
225223
226224describe ( 'complex expressions' , ( ) => {
227225 it ( 'price deviation from MA (%)' , async ( ) => {
228- // latest close = 149, SMA(50) of 100..149 = average of last 50 = 124.5
229- // (149 - 124.5) / 124.5 * 100 ≈ 19.68%
230- const result = await calc (
226+ const result = ( await calc (
231227 "(CLOSE('AAPL', '1d')[-1] - SMA(CLOSE('AAPL', '1d'), 50)) / SMA(CLOSE('AAPL', '1d'), 50) * 100" ,
232- )
228+ ) ) . value
233229 expect ( result ) . toBeCloseTo ( 19.68 , 1 )
234230 } )
235231
236232 it ( 'arithmetic on function results' , async ( ) => {
237- // MAX - MIN of all 50 closes = 149 - 100 = 49
238- const result = await calc ( "MAX(CLOSE('AAPL', '1d')) - MIN(CLOSE('AAPL', '1d'))" )
233+ const result = ( await calc ( "MAX(CLOSE('AAPL', '1d')) - MIN(CLOSE('AAPL', '1d'))" ) ) . value
239234 expect ( result ) . toBe ( 49 )
240235 } )
241236
242237 it ( 'double-quoted strings work' , async ( ) => {
243- const result = await calc ( 'CLOSE("AAPL", "1d")' )
238+ const result = ( await calc ( 'CLOSE("AAPL", "1d")' ) ) . value
244239 expect ( Array . isArray ( result ) ) . toBe ( true )
245240 expect ( ( result as number [ ] ) . length ) . toBe ( 50 )
246241 } )
@@ -250,35 +245,54 @@ describe('complex expressions', () => {
250245
251246describe ( 'precision' , ( ) => {
252247 it ( 'default precision = 4' , async ( ) => {
253- const result = ( await calc ( '10 / 3' ) ) as number
254- expect ( result ) . toBe ( 3.3333 )
248+ expect ( ( await calc ( '10 / 3' ) ) . value ) . toBe ( 3.3333 )
255249 } )
256250
257251 it ( 'custom precision = 2' , async ( ) => {
258- const result = ( await calc ( '10 / 3' , 2 ) ) as number
259- expect ( result ) . toBe ( 3.33 )
252+ expect ( ( await calc ( '10 / 3' , 2 ) ) . value ) . toBe ( 3.33 )
260253 } )
261254
262255 it ( 'precision = 0 rounds to integer' , async ( ) => {
263- const result = ( await calc ( '10 / 3' , 0 ) ) as number
264- expect ( result ) . toBe ( 3 )
256+ expect ( ( await calc ( '10 / 3' , 0 ) ) . value ) . toBe ( 3 )
265257 } )
266258
267- it ( 'precision applies to arrays' , async ( ) => {
268- const result = ( await calc ( "STDEV(CLOSE('AAPL', '1d'))" , 0 ) ) as number
269- expect ( result ) . toBe ( 14 )
259+ it ( 'precision applies to scalars from functions' , async ( ) => {
260+ expect ( ( await calc ( "STDEV(CLOSE('AAPL', '1d'))" , 0 ) ) . value ) . toBe ( 14 )
270261 } )
271262
272263 it ( 'precision applies to record values' , async ( ) => {
273- const result = ( await calc ( "BBANDS(CLOSE('AAPL', '1d'), 20, 2)" , 2 ) ) as Record < string , number >
274- // 所有值应只有 2 位小数
264+ const result = ( await calc ( "BBANDS(CLOSE('AAPL', '1d'), 20, 2)" , 2 ) ) . value as Record < string , number >
275265 for ( const v of Object . values ( result ) ) {
276266 const decimals = v . toString ( ) . split ( '.' ) [ 1 ] ?. length ?? 0
277267 expect ( decimals ) . toBeLessThanOrEqual ( 2 )
278268 }
279269 } )
280270} )
281271
272+ // ==================== dataRange 溯源 ====================
273+
274+ describe ( 'dataRange' , ( ) => {
275+ it ( 'calculate returns dataRange with symbol metadata' , async ( ) => {
276+ const { value, dataRange } = await calc ( "CLOSE('AAPL', '1d')[-1]" )
277+ expect ( value ) . toBe ( 149 )
278+ expect ( dataRange ) . toHaveProperty ( 'AAPL' )
279+ expect ( dataRange . AAPL . from ) . toBe ( mockData [ 0 ] . date )
280+ expect ( dataRange . AAPL . to ) . toBe ( mockData [ 49 ] . date )
281+ expect ( dataRange . AAPL . bars ) . toBe ( 50 )
282+ } )
283+
284+ it ( 'multiple symbols produce multiple dataRange entries' , async ( ) => {
285+ // ATR uses HIGH, LOW, CLOSE — all same symbol, should produce one entry
286+ const { dataRange } = await calc ( "ATR(HIGH('AAPL', '1d'), LOW('AAPL', '1d'), CLOSE('AAPL', '1d'), 14)" )
287+ expect ( Object . keys ( dataRange ) ) . toEqual ( [ 'AAPL' ] )
288+ } )
289+
290+ it ( 'pure arithmetic has empty dataRange' , async ( ) => {
291+ const { dataRange } = await calc ( '2 + 3' )
292+ expect ( Object . keys ( dataRange ) . length ) . toBe ( 0 )
293+ } )
294+ } )
295+
282296// ==================== 错误处理 ====================
283297
284298describe ( 'errors' , ( ) => {
0 commit comments