From 598424adf8f8001cbabf0cc1ec6e9065f1e58509 Mon Sep 17 00:00:00 2001 From: bludnic Date: Tue, 15 Oct 2024 03:22:14 +0100 Subject: [PATCH] feat: SMA and EMA indicators --- .../indicators/__snapshots__/ema.test.ts.snap | 234 ++++++++++++++++++ .../indicators/__snapshots__/sma.test.ts.snap | 234 ++++++++++++++++++ .../indicators/src/indicators/ema.test.ts | 43 ++++ packages/indicators/src/indicators/ema.ts | 56 +++++ .../indicators/src/indicators/sma.test.ts | 43 ++++ packages/indicators/src/indicators/sma.ts | 56 +++++ 6 files changed, 666 insertions(+) create mode 100644 packages/indicators/src/indicators/__snapshots__/ema.test.ts.snap create mode 100644 packages/indicators/src/indicators/__snapshots__/sma.test.ts.snap create mode 100644 packages/indicators/src/indicators/ema.test.ts create mode 100644 packages/indicators/src/indicators/ema.ts create mode 100644 packages/indicators/src/indicators/sma.test.ts create mode 100644 packages/indicators/src/indicators/sma.ts diff --git a/packages/indicators/src/indicators/__snapshots__/ema.test.ts.snap b/packages/indicators/src/indicators/__snapshots__/ema.test.ts.snap new file mode 100644 index 00000000..54984575 --- /dev/null +++ b/packages/indicators/src/indicators/__snapshots__/ema.test.ts.snap @@ -0,0 +1,234 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ema > should calculate EMA 1`] = ` +[ + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + 1945.1164285714283, + 1960.2342380952377, + 1960.431006349206, + 1960.468205502645, + 1960.752444768959, + 1967.5201187997645, + 1974.704102959796, + 1969.19155589849, + 1981.743348445358, + 1992.44423531931, + 2004.209003943402, + 2014.7211367509483, + 2021.067651850822, + 2021.979964937379, + 2025.487969612395, + 2025.930906997409, + 2029.3934527310878, + 2037.1223257002762, + 2054.1366822735727, + 2072.62645797043, + 2095.3722635743725, + 2121.7652950977895, + 2136.580589084751, + 2165.793843873451, + 2191.509331356991, + 2211.3907538427256, + 2230.1719866636954, + 2229.457055108536, + 2225.9814477607315, + 2230.518588059301, + 2241.796109651394, + 2238.9712950312082, + 2237.6044556937136, + 2232.1211949345516, + 2230.418368943278, + 2223.401253084174, + 2220.569086006284, + 2223.0798745387797, + 2236.6438912669423, + 2246.19137243135, + 2248.567189440503, + 2251.571564181769, + 2248.7873556242, + 2266.0397082076397, + 2276.462413779954, + 2279.4927586092936, + 2281.103057461388, + 2281.2226497998695, + 2290.6062964932203, + 2299.218790294124, + 2287.2816182549077, + 2284.59073582092, + 2282.430637711464, + 2276.899886016602, + 2269.525234547722, + 2277.6952032746926, + 2286.586509504734, + 2326.2856415707693, + 2365.1822226946665, + 2386.196593002044, + 2411.7703806017716, + 2419.9089965215353, + 2432.161130318664, + 2452.878312942842, + 2463.1918712171296, + 2464.2049550548454, + 2467.8869610475326, + 2468.454032907862, + 2466.926828520147, + 2446.5605847174606, + 2419.373840088466, + 2394.82932807667, + 2371.338750999781, + 2357.5095841998104, + 2345.5883063065025, + 2333.7525321323023, + 2331.6215278479954, + 2333.1066574682627, + 2326.457769805828, + 2323.4607338317173, + 2321.5206359874883, + 2318.1965511891567, + 2314.3850110306025, + 2312.701676226522, + 2320.704119396319, + 2334.63690347681, + 2345.962649679902, + 2364.712963055915, + 2382.7845679817933, + 2399.359958917554, + 2434.0866310618803, + 2461.5524135869628, + 2503.320091775368, + 2545.9294128719857, + 2580.0721578223875, + 2607.5505367794026, + 2644.0384652088155, + 2684.08666984764, + 2728.1617805346214, + 2760.0308764633387, + 2788.213426268227, + 2806.070302765797, + 2830.923595730357, + 2868.5577829663093, + 2909.5140785708013, + 2953.9002014280277, + 3011.124174570957, + 3054.9996179614964, + 3105.3916688999634, + 3147.515446379968, + 3192.8947201959722, + 3250.842090836509, + 3291.180478724975, + 3361.6337482283116, + 3429.23458179787, + 3489.7633042248203, + 3545.128196994844, + 3589.5671040621983, + 3652.942156853905, + 3696.5525359400513, + 3737.680197814711, + 3756.9335047727495, + 3755.0223708030494, + 3724.1007213626426, + 3713.448625180957, + 3687.790141823496, + 3617.22345624703, + 3603.7029954140926, + 3588.9172626922136, + 3555.3016276665853, + 3525.2160773110404, + 3515.804600336235, + 3525.7199869580704, + 3533.922655363661, + 3529.704967981839, + 3533.8283055842608, + 3530.5498648396924, + 3527.1752161944, + 3542.8998540351467, + 3537.7772068304607, + 3503.262912586399, + 3477.6398575748794, + 3457.6878765648953, + 3439.0094930229093, + 3427.4202272865214, + 3430.996196981652, + 3466.2193707174315, + 3471.4981212884404, + 3481.351705116648, + 3484.183477767762, + 3451.3030140653937, + 3391.8879455233414, + 3360.237552786896, + 3325.8058790819764, + 3293.5650952043798, + 3252.449749177129, + 3227.375115953512, + 3204.5744338263767, + 3198.0458426495265, + 3191.298396962923, + 3192.5426107012, + 3196.1009292743734, + 3188.678138704457, + 3184.3370535438626, + 3177.282779738014, + 3187.7530757729455, + 3197.847332336553, + 3200.401021358346, + 3175.6075518438997, + 3148.4932115980464, + 3126.9207833849737, + 3123.694012266977, + 3122.82281063138, + 3124.6584358805294, + 3116.4266444297923, + 3101.7564251724866, + 3084.7222351494884, + 3078.2552704628897, + 3055.8305677345043, + 3036.703825369904, + 3022.42198198725, + 3012.8737177222833, + 2995.4518886926458, + 3000.3556368669597, + 2992.9495519513653, + 3006.1229450245164, +] +`; + +exports[`ema > should calculate EMA for 1h candles 1`] = ` +[ + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + 2909.5485714285714, + 2910.8114285714287, + 2912.119238095238, + 2913.4553396825395, + 2913.1066277248674, + 2912.859077361552, + 2912.0912003800117, + 2910.98170699601, + 2909.641479396542, + 2910.215948810336, + 2909.1711556356245, +] +`; diff --git a/packages/indicators/src/indicators/__snapshots__/sma.test.ts.snap b/packages/indicators/src/indicators/__snapshots__/sma.test.ts.snap new file mode 100644 index 00000000..d7521354 --- /dev/null +++ b/packages/indicators/src/indicators/__snapshots__/sma.test.ts.snap @@ -0,0 +1,234 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`sma > should calculate SMA 1`] = ` +[ + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + 1945.1164285714283, + 1960.2399999999998, + 1971.7292857142854, + 1980.8807142857138, + 1988.511428571428, + 1997.0657142857137, + 2005.669285714285, + 2009.0949999999993, + 2021.5907142857136, + 2017.3778571428563, + 2017.5978571428566, + 2019.7357142857138, + 2020.9878571428565, + 2019.1678571428565, + 2024.0978571428566, + 2021.9771428571423, + 2028.4192857142855, + 2037.465714285714, + 2051.9035714285715, + 2064.8535714285713, + 2080.697857142857, + 2106.4092857142855, + 2118.52, + 2139.497142857143, + 2159.3528571428574, + 2177.7507142857144, + 2198.46, + 2212.524285714286, + 2223.6028571428574, + 2240.117142857143, + 2258.9171428571426, + 2268.435, + 2273.005714285714, + 2273.267857142857, + 2271.5628571428565, + 2263.310714285714, + 2261.116428571428, + 2252.810714285714, + 2250.392857142857, + 2248.0807142857143, + 2241.777857142857, + 2245.0842857142857, + 2247.0342857142855, + 2255.4749999999995, + 2257.5542857142855, + 2263.167142857142, + 2267.656428571428, + 2273.7649999999994, + 2283.211428571428, + 2295.883571428571, + 2296.421428571428, + 2298.399999999999, + 2294.369999999999, + 2289.562857142856, + 2286.5328571428563, + 2290.7971428571423, + 2298.9178571428565, + 2313.6428571428564, + 2333.2, + 2349.1714285714284, + 2369.630714285714, + 2383.2599999999993, + 2394.702857142857, + 2411.298571428571, + 2434.1942857142853, + 2448.743571428571, + 2464.702857142857, + 2481.2164285714284, + 2498.031428571429, + 2496.844285714286, + 2489.578571428571, + 2464.6471428571426, + 2436.121428571428, + 2417.895, + 2395.7592857142854, + 2380.3314285714287, + 2366.472142857143, + 2348.987857142857, + 2331.3457142857146, + 2319.4307142857147, + 2306.3657142857146, + 2293.826428571429, + 2281.8700000000003, + 2280.9828571428575, + 2290.2728571428574, + 2303.8378571428575, + 2318.19, + 2333.8307142857147, + 2350.412857142858, + 2368.2900000000004, + 2392.721428571429, + 2413.9585714285718, + 2449.0707142857145, + 2486.135714285715, + 2521.3564285714288, + 2556.325714285715, + 2598.582857142858, + 2644.485714285715, + 2690.337857142858, + 2729.0507142857155, + 2768.4664285714293, + 2799.577142857144, + 2834.7357142857154, + 2878.027142857144, + 2914.878571428573, + 2957.902142857144, + 3001.3500000000017, + 3038.300000000002, + 3083.3671428571447, + 3128.735714285716, + 3172.067857142859, + 3220.860714285716, + 3259.3414285714302, + 3320.2271428571444, + 3384.315714285716, + 3452.9628571428584, + 3518.1435714285726, + 3572.8035714285725, + 3636.314285714286, + 3689.0007142857144, + 3733.424285714286, + 3772.1307142857145, + 3794.2492857142856, + 3801.52, + 3812.687857142857, + 3805.0814285714287, + 3776.878571428572, + 3755.1814285714286, + 3728.3364285714283, + 3689.3078571428573, + 3648.212142857143, + 3617.941428571429, + 3584.0335714285716, + 3555.9778571428574, + 3520.069285714286, + 3497.1085714285714, + 3480.4399999999996, + 3479.163571428571, + 3479.227857142857, + 3478.0471428571427, + 3486.645714285714, + 3472.0221428571426, + 3460.25, + 3458.878571428571, + 3460.480714285714, + 3460.452857142857, + 3467.952857142857, + 3462.1364285714285, + 3465.215714285714, + 3461.0699999999997, + 3441.6657142857143, + 3405.983571428572, + 3370.940714285715, + 3342.192142857143, + 3328.269285714286, + 3304.991428571429, + 3286.162142857143, + 3267.502857142858, + 3253.4685714285724, + 3231.554285714287, + 3196.230000000001, + 3175.7600000000016, + 3146.8335714285727, + 3122.0857142857158, + 3114.5035714285727, + 3132.3692857142864, + 3140.1514285714293, + 3148.365714285715, + 3143.3978571428574, + 3142.4728571428577, + 3136.923571428572, + 3140.234285714286, + 3137.487857142857, + 3136.712857142857, + 3126.8764285714283, + 3111.6742857142854, + 3099.786428571428, + 3091.2221428571424, + 3075.4107142857138, + 3050.8799999999997, + 3027.032142857143, + 3008.0185714285712, + 2998.5728571428567, + 3002.8571428571427, + 2999.8650000000002, + 2999.0814285714287, +] +`; + +exports[`sma > should calculate SMA for 1h candles 1`] = ` +[ + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + NaN, + 2909.5485714285714, + 2909.5849999999996, + 2909.535, + 2911.652857142857, + 2911.883571428571, + 2912.5671428571427, + 2913.561428571428, + 2913.346428571428, + 2913.024285714285, + 2913.3014285714276, + 2912.4692857142845, +] +`; diff --git a/packages/indicators/src/indicators/ema.test.ts b/packages/indicators/src/indicators/ema.test.ts new file mode 100644 index 00000000..f4881e5d --- /dev/null +++ b/packages/indicators/src/indicators/ema.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ICandlestick } from "@opentrader/types"; + +import { ema } from "./ema.js"; + +const loadCandles = (filename: string): ICandlestick[] => { + return JSON.parse(readFileSync(join(__dirname, `./__mocks__/${filename}`), "utf-8")); +}; + +describe("ema", () => { + const candles: ICandlestick[] = loadCandles("ETH_USDT-1d-candles.json"); + const candles1h: ICandlestick[] = loadCandles("ETH_USDT-1h-candles.json"); + + it("should calculate EMA", async () => { + const result = await ema({ periods: 14 }, candles); + + expect(result).toMatchSnapshot(); + }); + + it("should throw an error if there are less than 2 periods", async () => { + await expect(ema({ periods: 1 }, candles)).rejects.toThrow("EMA requires at least 2 periods"); + }); + + it("should throw an error if no candles are provided", async () => { + await expect(ema({ periods: 14 }, [])).rejects.toThrow("No candles provided"); + }); + + it("the length of EMA values must be equal to the number of candles", async () => { + const emaValues = await ema({ periods: 14 }, candles); + expect(emaValues.length).toBe(candles.length); + + const emaValues2 = await ema({ periods: 14 }, candles.slice(0, 1)); + expect(emaValues2.length).toBe(1); + }); + + it("should calculate EMA for 1h candles", async () => { + const result = await ema({ periods: 14 }, candles1h); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/indicators/src/indicators/ema.ts b/packages/indicators/src/indicators/ema.ts new file mode 100644 index 00000000..49947906 --- /dev/null +++ b/packages/indicators/src/indicators/ema.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2024 bludnic + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Repository URL: https://github.com/bludnic/opentrader + */ +import type { ICandlestick } from "@opentrader/types"; +import { EMA } from "technicalindicators"; +import { IndicatorError } from "../utils/indicator.error.js"; + +type EmaParams = { + periods: number; +}; + +/** + * Calculate the Exponential Moving Average (EMA). + * + * @param params - EMA params + * @param candles - List of candles + * @returns The EMA values. If there are not enough data points to calculate the EMA, the initial values will be NaN. + */ +export async function ema( + params: EmaParams, + candles: ICandlestick[], +): Promise { + const prices = candles.map((candle) => candle.close); + + if (params.periods < 2) { + throw new IndicatorError("EMA requires at least 2 periods", "EMA"); + } + + if (candles.length < 1) { + throw new IndicatorError("No candles provided", "EMA"); + } + + const indicatorValues = EMA.calculate({ + period: params.periods, + values: prices, + }); + const emptyIndicatorValue = new Array( + candles.length - indicatorValues.length, + ).fill(NaN); + + return [...emptyIndicatorValue, ...indicatorValues]; +} diff --git a/packages/indicators/src/indicators/sma.test.ts b/packages/indicators/src/indicators/sma.test.ts new file mode 100644 index 00000000..c9bb2dc4 --- /dev/null +++ b/packages/indicators/src/indicators/sma.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ICandlestick } from "@opentrader/types"; + +import { sma } from "./sma.js"; + +const loadCandles = (filename: string): ICandlestick[] => { + return JSON.parse(readFileSync(join(__dirname, `./__mocks__/${filename}`), "utf-8")); +}; + +describe("sma", () => { + const candles: ICandlestick[] = loadCandles("ETH_USDT-1d-candles.json"); + const candles1h: ICandlestick[] = loadCandles("ETH_USDT-1h-candles.json"); + + it("should calculate SMA", async () => { + const result = await sma({ periods: 14 }, candles); + + expect(result).toMatchSnapshot(); + }); + + it("should throw an error if there are less than 2 periods", async () => { + await expect(sma({ periods: 1 }, candles)).rejects.toThrow("SMA requires at least 2 periods"); + }); + + it("should throw an error if no candles are provided", async () => { + await expect(sma({ periods: 14 }, [])).rejects.toThrow("No candles provided"); + }); + + it("the length of SMA values must be equal to the number of candles", async () => { + const smaValues = await sma({ periods: 14 }, candles); + expect(smaValues.length).toBe(candles.length); + + const smaValues2 = await sma({ periods: 14 }, candles.slice(0, 1)); + expect(smaValues2.length).toBe(1); + }); + + it("should calculate SMA for 1h candles", async () => { + const result = await sma({ periods: 14 }, candles1h); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/indicators/src/indicators/sma.ts b/packages/indicators/src/indicators/sma.ts new file mode 100644 index 00000000..70158f44 --- /dev/null +++ b/packages/indicators/src/indicators/sma.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2024 bludnic + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Repository URL: https://github.com/bludnic/opentrader + */ +import type { ICandlestick } from "@opentrader/types"; +import { SMA } from "technicalindicators"; +import { IndicatorError } from "../utils/indicator.error.js"; + +type SmaParams = { + periods: number; +}; + +/** + * Calculate the Simple Moving Average (SMA). + * + * @param params - SMA params + * @param candles - List of candles + * @returns The SMA values. If there are not enough data points to calculate the SMA, the initial values will be NaN. + */ +export async function sma( + params: SmaParams, + candles: ICandlestick[], +): Promise { + const prices = candles.map((candle) => candle.close); + + if (params.periods < 2) { + throw new IndicatorError("SMA requires at least 2 periods", "SMA"); + } + + if (candles.length < 1) { + throw new IndicatorError("No candles provided", "SMA"); + } + + const indicatorValues = SMA.calculate({ + period: params.periods, + values: prices, + }); + const emptyIndicatorValue = new Array( + candles.length - indicatorValues.length, + ).fill(NaN); + + return [...emptyIndicatorValue, ...indicatorValues]; +}