Skip to content

Commit

Permalink
Added ATR and expression fact plus cloning params (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
srcmayte authored May 23, 2024
1 parent 92ffea8 commit 51518a4
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 3 deletions.
5 changes: 4 additions & 1 deletion lib/facts/base.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const _ = require('lodash')
const { ValidationError } = require('../errors')
const { validate } = require('../validator')

Expand All @@ -18,7 +19,9 @@ class Fact {
return {}
}

value (params = {}) {
value (originalParams = {}) {
const params = _.cloneDeep(originalParams)

this.#validateAndSanitize(params)

return params
Expand Down
33 changes: 33 additions & 0 deletions lib/facts/expression.js
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion lib/facts/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
ta: require('./ta'),
market: require('./market')
market: require('./market'),
expression: require('./expression')
}
57 changes: 57 additions & 0 deletions lib/facts/ta/atr.js
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/facts/ta/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module.exports = {
ema: require('./ema'),
sma: require('./sma'),
atr: require('./atr'),
rsi: require('./rsi'),
crossUp: require('./crossUp'),
crossDown: require('./crossDown')
Expand Down
2 changes: 1 addition & 1 deletion lib/strategyEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
67 changes: 67 additions & 0 deletions test/facts/expression.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
179 changes: 179 additions & 0 deletions test/facts/ta/atr.spec.js
Original file line number Diff line number Diff line change
@@ -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
])
})
})
})

0 comments on commit 51518a4

Please sign in to comment.