Skip to content

Commit 953fd91

Browse files
authored
Merge pull request #121 from TraderAlice/dev
feat: commodity canonical naming, data provenance, provider bugfix
2 parents 578b0d1 + 2f4f577 commit 953fd91

File tree

16 files changed

+670
-201
lines changed

16 files changed

+670
-201
lines changed

packages/opentypebb/src/providers/fmp/models/commodity-spot-price.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,39 @@ import { CommoditySpotPriceQueryParamsSchema, CommoditySpotPriceDataSchema } fro
1111
import { getHistoricalOhlc } from '../utils/helpers.js'
1212
import { EmptyDataError } from '../../../core/provider/utils/errors.js'
1313

14+
// Canonical commodity names → FMP ticker symbols
15+
// Mirrors yfinance's COMMODITY_MAP pattern for provider-agnostic naming
16+
const COMMODITY_MAP: Record<string, string> = {
17+
// Precious metals
18+
gold: 'GCUSD',
19+
silver: 'SIUSD',
20+
platinum: 'PLUSD',
21+
palladium: 'PAUSD',
22+
// Industrial metals
23+
copper: 'HGUSD',
24+
// Energy
25+
crude_oil: 'CLUSD',
26+
wti: 'CLUSD',
27+
brent: 'BZUSD',
28+
natural_gas: 'NGUSD',
29+
heating_oil: 'HOUSD',
30+
gasoline: 'RBUSD',
31+
// Agriculture (may require higher FMP tier)
32+
corn: 'ZCUSX',
33+
wheat: 'KEUSX',
34+
soybeans: 'ZSUSX',
35+
// Softs (may require higher FMP tier)
36+
sugar: 'SBUSX',
37+
coffee: 'KCUSX',
38+
cocoa: 'CCUSX',
39+
cotton: 'CTUSX',
40+
}
41+
42+
function resolveSymbol(sym: string): string {
43+
const lower = sym.toLowerCase().trim()
44+
return COMMODITY_MAP[lower] ?? sym.trim()
45+
}
46+
1447
export const FMPCommoditySpotPriceQueryParamsSchema = CommoditySpotPriceQueryParamsSchema
1548
export type FMPCommoditySpotPriceQueryParams = z.infer<typeof FMPCommoditySpotPriceQueryParamsSchema>
1649

@@ -39,9 +72,10 @@ export class FMPCommoditySpotPriceFetcher extends Fetcher {
3972
query: FMPCommoditySpotPriceQueryParams,
4073
credentials: Record<string, string> | null,
4174
): Promise<Record<string, unknown>[]> {
75+
const symbols = query.symbol.split(',').map(s => resolveSymbol(s)).join(',')
4276
return getHistoricalOhlc(
4377
{
44-
symbol: query.symbol,
78+
symbol: symbols,
4579
interval: '1d',
4680
start_date: query.start_date,
4781
end_date: query.end_date,

src/domain/analysis/indicator/calculator.spec.ts

Lines changed: 77 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
* Indicator Calculator unit tests
33
*
44
* 覆盖:四则运算、运算符优先级、数据访问、统计函数、技术指标、
5-
* 数组索引、嵌套表达式、精度控制、错误处理。
5+
* 数组索引、嵌套表达式、精度控制、错误处理、数据溯源(dataRange)
66
*/
77
import { describe, it, expect } from 'vitest'
88
import { 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 测边界
1212
const mockData: OhlcvData[] = Array.from({ length: 50 }, (_, i) => ({
@@ -20,12 +20,18 @@ const mockData: OhlcvData[] = Array.from({ length: 50 }, (_, i) => ({
2020
}))
2121

2222
const 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

3541
describe('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

8994
describe('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

126130
describe('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

147151
describe('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

191190
describe('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

226224
describe('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

251246
describe('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

284298
describe('errors', () => {

0 commit comments

Comments
 (0)