diff --git a/lib/facts/base.js b/lib/facts/base.js index 8db9f90..28979f3 100644 --- a/lib/facts/base.js +++ b/lib/facts/base.js @@ -1,3 +1,4 @@ +const _ = require('lodash') const { ValidationError } = require('../errors') const { validate } = require('../validator') @@ -18,7 +19,9 @@ class Fact { return {} } - value (params = {}) { + value (originalParams = {}) { + const params = _.cloneDeep(originalParams) + this.#validateAndSanitize(params) return params diff --git a/lib/facts/expression.js b/lib/facts/expression.js new file mode 100644 index 0000000..314a22e --- /dev/null +++ b/lib/facts/expression.js @@ -0,0 +1,33 @@ +const jsonata = require('jsonata') + +const Fact = require('./base') + +class Expression extends Fact { + get id () { + return 'expression' + } + + get name () { + return 'Evaluate Expression' + } + + get description () { + return 'Evaluate an expression against provided paramters.' + } + + get schema () { + return { + expression: { type: 'string', required: true, description: 'The expression.' }, + data: { type: 'object', optional: true, default: {}, description: 'The data to evaluate the expression against.' }, + $$strict: true + } + } + + async value (params = {}) { + params = super.value(params) + + return jsonata(params.expression).evaluate(params.data) + } +} + +module.exports = Expression diff --git a/lib/facts/index.js b/lib/facts/index.js index 5888626..94da5e4 100644 --- a/lib/facts/index.js +++ b/lib/facts/index.js @@ -1,4 +1,5 @@ module.exports = { ta: require('./ta'), - market: require('./market') + market: require('./market'), + expression: require('./expression') } diff --git a/lib/facts/ta/atr.js b/lib/facts/ta/atr.js new file mode 100644 index 0000000..eafaaa4 --- /dev/null +++ b/lib/facts/ta/atr.js @@ -0,0 +1,57 @@ +const _ = require('lodash') +const BigNumber = require('bignumber.js') +const techIndicators = require('technicalindicators') + +const Fact = require('../base') + +class ATR extends Fact { + get id () { + return 'ta.atr' + } + + get name () { + return 'Average True Range (ATR)' + } + + get description () { + return 'Calculate the Average True Range from values over period' + } + + get schema () { + return { + values: { + type: 'array', + items: { + type: 'object', + props: { + high: { type: 'number', required: true, convert: true, description: 'The open value' }, + low: { type: 'number', required: true, convert: true, description: 'The high value' }, + close: { type: 'number', required: true, convert: true, description: 'The low value' } + }, + strict: 'remove', + convert: true + }, + optional: true, + default: [], + description: 'Values to apply indicator to.' + }, + period: { type: 'number', optional: true, default: 14, min: 1, convert: true, description: 'The period to calculate.' }, + $$strict: 'remove' + } + } + + async value (params = {}) { + params = super.value(params) + + const atr = techIndicators.ATR.calculate({ + high: params.values.map(v => BigNumber(v.high).toNumber()), + low: params.values.map(v => BigNumber(v.low).toNumber()), + close: params.values.map(v => BigNumber(v.close).toNumber()), + period: params.period + }) + + return _.takeRight(atr, params.period) + } +} + +module.exports = ATR diff --git a/lib/facts/ta/index.js b/lib/facts/ta/index.js index 7909675..b3dde3d 100644 --- a/lib/facts/ta/index.js +++ b/lib/facts/ta/index.js @@ -1,6 +1,7 @@ module.exports = { ema: require('./ema'), sma: require('./sma'), + atr: require('./atr'), rsi: require('./rsi'), crossUp: require('./crossUp'), crossDown: require('./crossDown') diff --git a/lib/strategyEngine.js b/lib/strategyEngine.js index 29e188e..cd2b46c 100644 --- a/lib/strategyEngine.js +++ b/lib/strategyEngine.js @@ -12,7 +12,7 @@ class StrategyEngine { this.#validateAndSanitizeOpts(opts) this.engine = new Engine(undefined, { - pathResolver: (object, path) => jsonata(path).evaluate(object) + pathResolver: (data, path) => jsonata(path).evaluate(data) }) opts.rules.forEach(rule => { diff --git a/test/facts/expression.spec.js b/test/facts/expression.spec.js new file mode 100644 index 0000000..49e8e2d --- /dev/null +++ b/test/facts/expression.spec.js @@ -0,0 +1,67 @@ +const { expect } = require('../helpers') +const Expression = require('../../lib/facts/expression') + +describe('Fact expression', () => { + describe('#value', () => { + const fact = new Expression() + + describe('params', () => { + const defaultParams = { + expression: 'a + b', + data: { a: 1, b: 2 } + } + + describe('expression', () => { + it('is required', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, expression: undefined }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('expression is required') + }) + + it('must a string', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, expression: {} }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('expression must be a string') + }) + }) + + describe('data', () => { + it('must be an object', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, data: 'invalid' }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('data must be an Object') + }) + }) + }) + + it('evaluates expression against provided data', async () => { + const result = await fact.value({ + expression: 'a + b', + data: { a: 1, b: 2 } + }) + + expect(result).to.eql(3) + }) + }) +}) diff --git a/test/facts/ta/atr.spec.js b/test/facts/ta/atr.spec.js new file mode 100644 index 0000000..e45325f --- /dev/null +++ b/test/facts/ta/atr.spec.js @@ -0,0 +1,179 @@ +const { expect } = require('../../helpers') +const ATR = require('../../../lib/facts/ta/atr') + +describe('Fact ta.atr', () => { + describe('#value', () => { + const fact = new ATR() + const defaultParams = { + values: [ + { high: 48.70, low: 47.79, close: 48.16 }, + { high: 48.72, low: 48.14, close: 48.61 }, + { high: 48.90, low: 48.39, close: 48.75 }, + { high: 48.87, low: 48.37, close: 48.63 }, + { high: 48.82, low: 48.24, close: 48.74 }, + { high: 49.05, low: 48.64, close: 49.03 }, + { high: 49.20, low: 48.94, close: 49.07 }, + { high: 49.35, low: 48.86, close: 49.32 }, + { high: 49.92, low: 49.50, close: 49.91 }, + { high: 50.19, low: 49.87, close: 50.13 }, + { high: 50.12, low: 49.20, close: 49.53 }, + { high: 49.66, low: 48.90, close: 49.50 }, + { high: 49.88, low: 49.43, close: 49.75 }, + { high: 50.19, low: 49.73, close: 50.03 }, + { high: 50.36, low: 49.26, close: 50.31 } + ], + period: 14 + } + + describe('params', () => { + describe('values', () => { + it('defaults to an empty array', async () => { + const result = await fact.value() + + expect(result).to.eql([]) + }) + + describe('props', () => { + describe('high', () => { + it('is required', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, values: [{ ...defaultParams.values[0], high: undefined }] }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('values[0].high is required') + }) + + it('must be a number', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, values: [{ ...defaultParams.values[0], high: {} }] }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('values[0].high must be a number') + }) + }) + + describe('low', () => { + it('is required', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, values: [{ ...defaultParams.values[0], low: undefined }] }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('values[0].low is required') + }) + + it('must be a number', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, values: [{ ...defaultParams.values[0], low: {} }] }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('values[0].low must be a number') + }) + }) + + describe('close', () => { + it('is required', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, values: [{ ...defaultParams.values[0], close: undefined }] }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('values[0].close is required') + }) + + it('must be a number', async () => { + let thrownErr = null + + try { + await fact.value({ ...defaultParams, values: [{ ...defaultParams.values[0], close: {} }] }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('values[0].close must be a number') + }) + }) + }) + }) + + describe('period', () => { + it('defaults to 14', async () => { + const result = await fact.value(defaultParams) + + expect(result).to.eql([0.5678571428571431]) + }) + + it('must be an number', async () => { + let thrownErr = null + + try { + await fact.value({ period: 'ten' }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('period must be a number') + }) + + it('must be at least 1', async () => { + let thrownErr = null + + try { + await fact.value({ period: 0 }) + } catch (err) { + thrownErr = err + } + + expect(thrownErr.type).to.eql('VALIDATION_ERROR') + expect(thrownErr.data[0].message).to.eql('period must be greater than or equal to 1') + }) + }) + }) + + it('returns the ATR', async () => { + const result = await fact.value(defaultParams) + + expect(result).to.eql([0.5678571428571431]) + }) + + it('only returns up to periods', async () => { + const result = await fact.value({ + values: defaultParams.values, + period: 5 + }) + + expect(result).to.eql([ + 0.5545580800000003, + 0.5956464639999999, + 0.5665171712000004, + 0.5452137369600005, + 0.6561709895680007 + ]) + }) + }) +})