calcObv.mjs

import isNumber from 'lodash-es/isNumber.js'
import size from 'lodash-es/size.js'
import each from 'lodash-es/each.js'


/**
 * 計算各週期之能量潮OBV(On Balance Volume)
 *
 * 各週期len為該期涵蓋之K線根數(以4hr K線為基準,1day=6根),資料筆數n小於len+1時,該期vs為空陣列
 * 累積OBV:Close較前一根上升時obv累加該根Volumn,下降時obv累減該根Volumn,持平則obv不變,obv[0]=0
 * OBVnorm=(obv-視窗len根obv之算術平均OBV_MA)/|OBV_MA|,OBV_MA=0時給0
 * OBVslope=(obv-len根前之obv)/(len*|len根前之obv|),len根前之obv為0時分母改為len
 * OBVdiv為obv與len根前之obv取正負號比較,與Close之len根漲跌方向相同給1,相反給-1,任一方向為0則給0
 * 各期第1筆對應輸入之第len根,其後逐根對齊至最後一根
 *
 * Unit Test: {@link https://github.com/yuda-lyu/w-data-trade/blob/master/test/unit-calcObv.test.mjs Github}
 * @function
 * @param {Array} arr 輸入K線陣列,各元素需含time、Close、Volumn等欄位
 * @param {String} key 輸入計算所用數值欄位名稱字串,目前未實際使用(內部固定採用Close、Volumn)
 * @param {Object} [opt={}] 輸入設定物件,預設{},目前未讀取任何鍵值(保留參數)
 * @returns {Promise} 回傳Promise,resolve為各期結果陣列,各元素為{period,len,vs},vs內各元素為{time,OBVnorm,OBVslope,OBVdiv}
 * @example
 *
 * let arr = [
 *     { time: '2020-01-01T00:00:00', Close: 10, Volumn: 1 },
 *     { time: '2020-01-01T04:00:00', Close: 12, Volumn: 2 },
 *     { time: '2020-01-01T08:00:00', Close: 11, Volumn: 3 },
 *     { time: '2020-01-01T12:00:00', Close: 13, Volumn: 4 },
 * ]
 *
 * calcObv(arr, 'Close')
 *     .then((rs) => {
 *         console.log(rs[0])
 *         // => {
 *         //   period: '12hr',
 *         //   len: 3,
 *         //   vs: [ { time: '2020-01-01T12:00:00', OBVnorm: 1.2500000000000002, OBVslope: 1, OBVdiv: 1 } ]
 *         // }
 *     })
 *
 */
let calcObv = (() => {

    let kp = {
        '12hr': 3,
        '16hr': 4,
        '20hr': 5,
        '1day': 6, // 1 day = 6 * 4 hours
        '2day': 12, // 2 days = 12 * 4 hours
        '4day': 24, // 4 days = 24 * 4 hours
        '7day': 42, // 7 days = 42 * 4 hours
        '15day': 90, // 15 days = 90 * 4 hours
        '30day': 180, // 30 days = 180 * 4 hours
    }

    let caObv = (arr, len, opt = {}) => {

        //check
        if (!isNumber(len)) {
            throw new Error(`len is not a number`)
        }

        //n
        let n = size(arr)

        //check
        if (n < len + 1) {
            return []
        }

        let kTime = 'time'
        let kClose = 'Close'
        let kVolumn = 'Volumn'

        //計算累積OBV序列
        let obvs = new Array(n).fill(0)
        obvs[0] = 0
        for (let i = 1; i < n; i++) {

            let c = arr[i][kClose]
            let cPrev = arr[i - 1][kClose]
            let vol = arr[i][kVolumn]

            //check
            if (!isNumber(c)) {
                throw new Error(`invalid c[${c}]`)
            }
            if (!isNumber(cPrev)) {
                throw new Error(`invalid cPrev[${cPrev}]`)
            }
            if (!isNumber(vol)) {
                throw new Error(`invalid vol[${vol}]`)
            }

            //OBV累積
            if (c > cPrev) {
                obvs[i] = obvs[i - 1] + vol
            }
            else if (c < cPrev) {
                obvs[i] = obvs[i - 1] - vol
            }
            else {
                obvs[i] = obvs[i - 1]
            }

        }

        //用running sum計算OBV的SMA(period)
        let sumObv = 0

        //rs
        let rs = []
        for (let i = 1; i < n; i++) {

            //加入running sum
            sumObv += obvs[i]

            //維護視窗大小, 扣掉超出視窗的最舊值
            let idxDrop = i - len
            if (idxDrop >= 1) {
                sumObv -= obvs[idxDrop]
            }

            //check, 視窗未滿len(需要從i=1開始累積len根)
            if (i < len) {
                continue
            }

            //OBV_MA = SMA(OBV, period)
            let obvMa = sumObv / len

            //OBVnorm = (OBV - OBV_MA) / OBV_MA
            let OBVnorm = obvMa !== 0 ? (obvs[i] - obvMa) / Math.abs(obvMa) : 0

            //OBVslope = (OBV - OBV[i-len]) / len, 正規化用abs(OBV[i-len])避免尺度問題
            let obvPast = obvs[i - len]
            let OBVslope = 0
            if (Math.abs(obvPast) > 0) {
                OBVslope = (obvs[i] - obvPast) / (len * Math.abs(obvPast))
            }
            else {
                OBVslope = (obvs[i] - obvPast) / len
            }

            //OBVdiv: OBV方向 vs 價格方向, 1=同向, -1=背離, 0=無方向
            let obvDir = Math.sign(obvs[i] - obvPast)
            let priceDir = Math.sign(arr[i][kClose] - arr[i - len][kClose])
            let OBVdiv = 0
            if (obvDir !== 0 && priceDir !== 0) {
                OBVdiv = obvDir === priceDir ? 1 : -1
            }

            //push
            rs.push({
                time: arr[i][kTime],
                OBVnorm,
                OBVslope,
                OBVdiv,
            })

        }
        // console.log('rs', rs)

        return rs
    }

    let caObvs = (arr, opt = {}) => {

        //rrs
        let rrs = []
        each(kp, (len, period) => {

            //caObv
            let rs = caObv(arr, len, opt)

            //push
            rrs.push({
                period,
                len,
                vs: rs,
            })
            // console.log('rrs', rrs)

        })

        return rrs
    }

    let calcObv = async(arr, key, opt = {}) => {
        // arr = [
        //   {"time":"2020-01-01T00:00:00","Open":7195,"High":7225.62,"Low":7145.01,"Close":7173.32,"Volumn":4657.972543,...},
        //   {"time":"2020-01-01T04:00:00","Open":7173.75,"High":7208.41,"Low":7165.1,"Close":7195.23,"Volumn":2091.720176,...},
        //   ...
        // ]

        //caObvs
        let rs = caObvs(arr, opt)

        return rs
    }

    return calcObv
})()


export default calcObv