calcSupertrend.mjs

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


/**
 * 計算各週期之Supertrend(Olivier Seban ATR通道趨勢指標)
 *
 * 各週期kp值為{len,mult}物件,len為ATR平滑期數(以4hr K線為基準之根數),mult為ATR通道厚度倍數;資料筆數n小於len+1時,該期vs為空陣列
 * TR(真實區間)=max(|High-Low|,|High-前一根Close|,|Low-前一根Close|),ATR種子為第1至len根TR之算術平均,其後以Wilder平滑遞推(atr=(atr*(len-1)+TR)/len)
 * basicUpper=(High+Low)/2+mult*ATR,basicLower=(High+Low)/2-mult*ATR
 * finalUpper僅在basicUpper較前值小或前一根Close高於前值時更新為basicUpper,否則鎖死沿用前值;finalLower僅在basicLower較前值大或前一根Close低於前值時更新為basicLower,否則鎖死沿用前值
 * 方向dir沿續前一根,多頭且Close跌破finalLower時翻空,空頭且Close突破finalUpper時翻多;首根依Close是否小於等於finalUpper判定初始方向
 * ST線line:多頭取finalLower,空頭取finalUpper
 * stDistance=(Close-line)/Close,Close=0時給0
 * stDistanceMod=diviProt(Close+opt.plusClose-line, Close+opt.plusClose),opt.plusClose=0時退化為stDistance;diviProt對分母絕對值小於0.00001時會鎖定為±0.00001(保留正負號)以避免除以0爆炸
 * 各期第1筆對應輸入之第len根,其後逐根對齊至最後一根
 *
 * Unit Test: {@link https://github.com/yuda-lyu/w-data-trade/blob/master/test/unit-calcSupertrend.test.mjs Github}
 * @function
 * @param {Array} arr 輸入K線陣列,各元素需含time、High、Low、Close等欄位
 * @param {String} key 輸入計算所用數值欄位名稱字串,目前未實際使用(內部固定採用High、Low、Close)
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {Number} [opt.plusClose=0] 輸入stDistanceMod計算時對Close之偏移量,用以避免Close接近0時除法爆炸,預設0
 * @returns {Promise} 回傳Promise,resolve為各期結果陣列,各元素為{period,len,vs},vs內各元素為{time,stDistance,stDistanceMod}
 * @example
 *
 * let arr = [
 *     { time: '2020-01-01T00:00:00', Open: 10, High: 10, Low: 10, Close: 10 },
 *     { time: '2020-01-01T04:00:00', Open: 10, High: 12, Low: 10, Close: 12 },
 *     { time: '2020-01-01T08:00:00', Open: 12, High: 12, Low: 11, Close: 11 },
 *     { time: '2020-01-01T12:00:00', Open: 11, High: 13, Low: 11, Close: 13 },
 * ]
 *
 * calcSupertrend(arr, 'Close')
 *     .then((rs) => {
 *         console.log(rs[0])
 *         // => {
 *         //   period: '12hr',
 *         //   len: 3,
 *         //   vs: [ { time: '2020-01-01T12:00:00', stDistance: -0.17948717948717954, stDistanceMod: -0.17948717948717954 } ]
 *         // }
 *     })
 *
 */
let calcSupertrend = (() => {

    //Supertrend 各 period 對應 (len, mult) 配對 (4hr K 線)
    //len  = ATR 平滑期數 (n × 4hr)
    //mult = ATR 通道厚度倍數 (k)
    //設計原則: period 越長 → ATR 反應越平滑 + 通道越厚, 兩者同向降翻轉敏感度
    //錨點: 1day=(6,3) 對應 Olivier Seban 原版 (10,3); 4day=(24,4) 對應 d12 grid 最佳 (20,4)
    let kp = {
        '12hr': { len: 3, mult: 2 },
        '16hr': { len: 4, mult: 2 },
        '20hr': { len: 5, mult: 2.5 },
        '1day': { len: 6, mult: 3 },
        '2day': { len: 12, mult: 3 },
        '4day': { len: 24, mult: 4 },
        '7day': { len: 42, mult: 4 },
        '15day': { len: 90, mult: 5 },
        '30day': { len: 180, mult: 5 },
    }

    let caSupertrend = (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`)
        }

        //n
        let n = size(arr)

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

        //plusClose
        let plusClose = get(opt, 'plusClose', 0)

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

        //計算各根 TR (從 i=1 開始, 需要前一根 Close)
        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 (對齊 calcAtr 風格)
        let sumTr = 0
        for (let i = 1; i <= len; i++) {
            sumTr += trs[i]
        }
        let atrPrev = sumTr / len

        //rs
        let rs = []

        //遞推狀態
        let finalUpperPrev = null
        let finalLowerPrev = null
        let closePrev = arr[len - 1][kClose] //「前一根 Close」用於 finalBand 重置判斷
        let dirPrev = null

        for (let i = len; i < n; i++) {

            //當前根 ATR (Wilder smoothing): i=len 用種子, 之後遞推
            let atrNow
            if (i === len) {
                atrNow = atrPrev
            }
            else {
                atrNow = (atrPrev * (len - 1) + trs[i]) / len
            }

            let h = arr[i][kHigh]
            let l = arr[i][kLow]
            let c = arr[i][kClose]

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

            //basic 通道
            let hl2 = (h + l) / 2
            let basicUpper = hl2 + mult * atrNow
            let basicLower = hl2 - mult * atrNow

            //final upper (鎖死: 只能下移或上根穿越時重置)
            let finalUpper
            if (finalUpperPrev === null) {
                finalUpper = basicUpper
            }
            else {
                if (basicUpper < finalUpperPrev || closePrev > finalUpperPrev) {
                    finalUpper = basicUpper
                }
                else {
                    finalUpper = finalUpperPrev
                }
            }

            //final lower (鎖死: 只能上移或上根跌破時重置)
            let finalLower
            if (finalLowerPrev === null) {
                finalLower = basicLower
            }
            else {
                if (basicLower > finalLowerPrev || closePrev < finalLowerPrev) {
                    finalLower = basicLower
                }
                else {
                    finalLower = finalLowerPrev
                }
            }

            //direction: 沿續前根, 收盤穿越對側通道時翻轉
            let dir
            if (dirPrev === null) {
                //初始化: close <= finalUpper → 視為空頭起點; 否則多頭
                dir = c <= finalUpper ? -1 : 1
            }
            else {
                dir = dirPrev
                if (dirPrev === 1 && c < finalLower) {
                    dir = -1
                }
                else if (dirPrev === -1 && c > finalUpper) {
                    dir = 1
                }
            }

            //ST 線: 多頭用 finalLower (下方支撐), 空頭用 finalUpper (上方壓力)
            let line = dir === 1 ? finalLower : finalUpper

            //stDistance = (Close - line) / Close  (原版, 保留供日後對比)
            //多頭: Close > line → 正值 (buffer 大小); 空頭: Close < line → 負值
            let stDistance = c !== 0 ? (c - line) / c : 0

            //stDistanceMod = (Close+plusClose - line) / (Close+plusClose)
            //  plusClose=0 時退化成原版 (c-line)/c (price 預設用法)
            //  plusClose>0 時把 Close 偏移正方向, 對 index OHLC (close 可 ≈0 或負) 修正除法爆炸
            //  diviProt 額外保護: |分母| < 0.00001 時 floor 至 ±0.00001 (保留 sign)
            let cp = c + plusClose
            let stDistanceMod = diviProt(cp - line, cp)

            //push (內含方向(sign) + 強度(magnitude), 對齊 d05 DE 搜門檻架構)
            rs.push({
                time: arr[i][kTime],
                stDistance,
                stDistanceMod,
            })

            //next iter
            finalUpperPrev = finalUpper
            finalLowerPrev = finalLower
            closePrev = c
            dirPrev = dir
            atrPrev = atrNow

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

        return rs
    }

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

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

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

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

        })

        return rrs
    }

    let calcSupertrend = 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,...},
        //   ...
        // ]

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

        return rs
    }

    return calcSupertrend
})()


export default calcSupertrend