calcKeltner.mjs

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


/**
 * 計算各週期之肯特納通道Keltner Channel
 *
 * 各期(period)對應之kp設定為{len,mult}參數配對(len單位為以4hr K線為基準之根數),
 * len為EMA(中線)與ATR(通道厚度基準)共用之平滑期數,mult為ATR通道厚度倍數,錨點1day=(len:6,mult:2)對應Linda Raschke經典(20,10,2)標準:
 *   '12hr': { len: 3,   mult: 1.5 }
 *   '16hr': { len: 4,   mult: 1.5 }
 *   '20hr': { len: 5,   mult: 1.5 }
 *   '1day':  { len: 6,   mult: 2 }
 *   '2day':  { len: 12,  mult: 2 }
 *   '4day':  { len: 24,  mult: 2 }
 *   '7day':  { len: 42,  mult: 2.5 }
 *   '15day': { len: 90,  mult: 2.5 }
 *   '30day': { len: 180, mult: 3 }
 * middle=EMA(Close,len),ATR以Wilder平滑遞推(種子為前len筆TR之算術平均SMA),upper=middle+mult*ATR,lower=middle-mult*ATR
 * 第1筆對應輸入索引len(此時EMA與ATR皆已就緒),資料筆數不足len+1時,該期vs為空陣列,其後逐根對齊至最後一根
 *
 * Unit Test: {@link https://github.com/yuda-lyu/w-data-trade/blob/master/test/unit-calcKeltner.test.mjs Github}
 * @function
 * @param {Array} arr 輸入K線陣列,各元素需含time、High、Low、Close欄位
 * @param {String} key 輸入計算所用數值欄位名稱字串,例如'Close'(僅供介面一致性,本函式實際計算取用High/Low/Close)
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {Number} [opt.plusMiddle=0] 輸入偏移middle之量,用於穩定kcWidthMod之分母,預設0
 * @returns {Promise} 回傳Promise,resolve為各期結果陣列,各元素為{period,len,vs},vs內各元素為{time,kcPctB,kcWidth,kcWidthMod}
 * @example
 *
 * let arr = [
 *     { time: '2020-01-01T00:00:00', Open: 101, High: 101.5, Low: 100.5, Close: 101 },
 *     { time: '2020-01-01T04:00:00', Open: 101, High: 105.17, Low: 99.28, Close: 103.45 },
 *     { time: '2020-01-01T08:00:00', Open: 103.45, High: 108.74, Low: 101.35, Close: 106.64 },
 *     { time: '2020-01-01T12:00:00', Open: 106.64, High: 111.54, Low: 104.68, Close: 109.58 },
 * ]
 *
 * calcKeltner(arr, 'Close')
 *     .then((rs) => {
 *         let r = rs.find((v) => v.period === '12hr')
 *         console.log(r.vs)
 *         // => [
 *         //   {
 *         //     time: '2020-01-01T12:00:00',
 *         //     kcPctB: 0.6460609069844429,
 *         //     kcWidth: 0.18886266664582768,
 *         //     kcWidthMod: 0.18886266664582768
 *         //   }
 *         // ]
 *     })
 *
 */
let calcKeltner = (() => {

    //Keltner Channel 各 period 對應 (len, mult) 配對 (4hr K 線)
    //len  = EMA 與 ATR 共用之平滑期數 (n × 4hr)
    //mult = ATR 通道厚度倍數 (k)
    //設計原則: 經典 Linda Raschke (20,10,2) 對應於 1day(6,2). Keltner 經典 k=2, 區間 1.5~3
    //錨點: 1day=(6,2) 對應 Raschke 標準
    let kp = {
        '12hr': { len: 3, mult: 1.5 },
        '16hr': { len: 4, mult: 1.5 },
        '20hr': { len: 5, mult: 1.5 },
        '1day': { len: 6, mult: 2 },
        '2day': { len: 12, mult: 2 },
        '4day': { len: 24, mult: 2 },
        '7day': { len: 42, mult: 2.5 },
        '15day': { len: 90, mult: 2.5 },
        '30day': { len: 180, mult: 3 },
    }

    let caKeltner = (arr, len, mult, opt = {}) => {

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

        //plusMiddle: 偏移 middle (EMA close) 為穩定 kcWidth 分母, 用於 kcWidthMod
        //  原 kcWidth = bandWidth / middle, index OHLC 之 middle 可 ≈0 或負 → ratio 爆
        //  kcWidthMod = bandWidth / (middle + plusMiddle), 用 diviProt 額外保護 |分母|<0.00001 邊界
        //  bandWidth = upper-lower ≥0 不需偏移
        //  middle 直接命名 (不沿用 plusClose) 因 middle 是 EMA(close), 跟 c 隔一層, 直接對應 middle 較清晰
        let plusMiddle = get(opt, 'plusMiddle', 0)

        //n
        let n = size(arr)

        //check, 須夠根數同時供 EMA 種子 (前 len 筆) 與 ATR 種子 (TR 從 i=1 起算共 len 筆)
        if (n < len + 1) {
            return []
        }

        let kTime = 'time'
        let kHigh = 'High'
        let kLow = 'Low'
        let kClose = 'Close'

        //計算各根 TR (從 i=1 開始, 需要前一根 Close), 對齊 calcAtr 風格
        let trs = new Array(n).fill(0)
        for (let i = 1; i < n; i++) {
            let h = arr[i][kHigh]
            let l = arr[i][kLow]
            let cPrev = arr[i - 1][kClose]
            if (!isNumber(h)) {
                throw new Error(`invalid h[${h}]`)
            }
            if (!isNumber(l)) {
                throw new Error(`invalid l[${l}]`)
            }
            if (!isNumber(cPrev)) {
                throw new Error(`invalid cPrev[${cPrev}]`)
            }
            let tr = Math.max(Math.abs(h - l), Math.abs(h - cPrev), Math.abs(l - cPrev))
            trs[i] = tr
        }

        //ATR 種子: 前 len 根 TR 的 SMA, 第一筆 ATR 對應 index = len (對齊 calcAtr)
        let sumTr = 0
        for (let i = 1; i <= len; i++) {
            sumTr += trs[i]
        }
        let atrPrev = sumTr / len

        //EMA 種子: 前 len 根 Close 的 SMA, 第一筆 EMA 對應 index = len - 1 (對齊 caEma)
        let sumClose = 0
        for (let i = 0; i < len; i++) {
            let c = arr[i][kClose]
            if (!isNumber(c)) {
                throw new Error(`invalid c[${c}]`)
            }
            sumClose += c
        }
        let ema0 = sumClose / len
        let alpha = 2 / (len + 1)

        //先 forward EMA 到 index = len (與 ATR 第一筆對齊)
        let cAtLen = arr[len][kClose]
        if (!isNumber(cAtLen)) {
            throw new Error(`invalid c[${cAtLen}]`)
        }
        let emaPrev = cAtLen * alpha + ema0 * (1 - alpha)

        //rs
        let rs = []

        //第一筆 Keltner: 對應 index = len (此時 EMA 與 ATR 皆已就緒)
        {
            let c = cAtLen
            let middle = emaPrev
            let upper = middle + mult * atrPrev
            let lower = middle - mult * atrPrev
            let bandWidth = upper - lower
            let kcPctB = bandWidth !== 0 ? (c - lower) / bandWidth : 0.5
            let kcWidth = middle !== 0 ? bandWidth / middle : 0
            let middlep = middle + plusMiddle
            let kcWidthMod = diviProt(bandWidth, middlep)

            rs.push({
                time: arr[len][kTime],
                kcPctB,
                kcWidth,
                kcWidthMod,
            })
        }

        //後續: EMA + ATR 遞推
        for (let i = len + 1; i < n; i++) {

            let c = arr[i][kClose]
            if (!isNumber(c)) {
                throw new Error(`invalid c[${c}]`)
            }

            //EMA 遞推 (對齊 caEma 公式)
            let ema = c * alpha + emaPrev * (1 - alpha)

            //ATR 遞推 (Wilder smoothing, 對齊 calcAtr 公式)
            let atr = (atrPrev * (len - 1) + trs[i]) / len

            //Keltner 通道
            let middle = ema
            let upper = middle + mult * atr
            let lower = middle - mult * atr
            let bandWidth = upper - lower
            let kcPctB = bandWidth !== 0 ? (c - lower) / bandWidth : 0.5
            let kcWidth = middle !== 0 ? bandWidth / middle : 0
            let middlep = middle + plusMiddle
            let kcWidthMod = diviProt(bandWidth, middlep)

            rs.push({
                time: arr[i][kTime],
                kcPctB,
                kcWidth,
                kcWidthMod,
            })

            //next iter
            emaPrev = ema
            atrPrev = atr

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

        return rs
    }

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

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

            //caKeltner
            let rs = caKeltner(arr, cfg.len, cfg.mult, opt)

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

        })

        return rrs
    }

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

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

        return rs
    }

    return calcKeltner
})()


export default calcKeltner