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'
/**
* 計算各週期之一目均衡表Ichimoku Cloud(Ichimoku Kinko Hyo)
*
* 三層lookback依經典9:26:52比例縮放,各期(period)對應之kp設定為{tenkan,kijun,senkouB}三參數配對(單位皆為以4hr K線為基準之根數),
* period標籤本身即對應senkouB(最長lookback)所涵蓋之天數,僅保留4day~30day四期(短於4day時tenkan會<4而失去意義):
* '4day': { tenkan: 4, kijun: 12, senkouB: 24 }
* '7day': { tenkan: 7, kijun: 21, senkouB: 42 }
* '15day': { tenkan: 16, kijun: 45, senkouB: 90 }
* '30day': { tenkan: 31, kijun: 90, senkouB: 180 }
* Tenkan=(視窗內最高High+最低Low)/2(視窗為最近tenkan根),Kijun同理(視窗為最近kijun根)
* SenkouA[i]=(Tenkan[i-kijun]+Kijun[i-kijun])/2,SenkouB[i]=(i-kijun期回看senkouB根之最高High+最低Low)/2
* cloudTop=max(SenkouA,SenkouB),cloudBot=min(SenkouA,SenkouB),cloudMid=(cloudTop+cloudBot)/2
* 第1筆對應輸入索引startIdx=max(2*kijun-1, kijun+senkouB-1),資料筆數不足startIdx+1時,該期vs為空陣列
* 不輸出Chikou(遲行線),因其原版定義為Close[i+26]之look-ahead資料,回測不可用
*
* Unit Test: {@link https://github.com/yuda-lyu/w-data-trade/blob/master/test/unit-calcIchimoku.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.plusClose=0] 輸入偏移Close之量,用於穩定cloudDistMod、cloudThickMod、tkDiffMod之分母,預設0
* @param {Number} [opt.plusCloudMid=0] 輸入偏移cloudMid之量,用於穩定cloudDistMod之分子,預設0
* @param {Number} [opt.plusBandWidth=0] 輸入偏移cloudTop-cloudBot(bandWidth)之量,用於穩定cloudThickMod之分子,預設0
* @param {Number} [opt.plusTenkans=0] 輸入偏移Tenkan(與Kijun同步)之量,用於穩定tkDiffMod之分子,預設0
* @returns {Promise} 回傳Promise,resolve為各期結果陣列,各元素為{period,len,vs},vs內各元素為{time,cloudDist,cloudThick,tkDiff,cloudDistMod,cloudThickMod,tkDiffMod}
* @example
*
* //最短之4day期需24根senkouB且再回看kijun根,故至少需36根K線,此處給40根
* let arr = []
* for (let i = 0; i < 40; i++) {
* let c = 100 + Math.sin(i * 0.7) * 5 + Math.cos(i * 0.31) * 3
* arr.push({
* time: new Date(Date.UTC(2020, 0, 1) + i * 4 * 3600 * 1000).toISOString().slice(0, 19),
* High: c + 1,
* Low: c - 1,
* Close: c,
* })
* }
*
* calcIchimoku(arr, 'Close')
* .then((rs) => {
* console.log(rs.map((v) => `${v.period}(len=${v.len}): ${v.vs.length} 筆`))
* // => [
* // '4day(len=24): 5 筆',
* // '7day(len=42): 0 筆',
* // '15day(len=90): 0 筆',
* // '30day(len=180): 0 筆'
* // ]
*
* let p4day = rs.find((v) => v.period === '4day')
* console.log(p4day.vs[p4day.vs.length - 1])
* // => {
* // time: '2020-01-07T12:00:00',
* // cloudDist: 0.06339165670785038,
* // cloudThick: 0.011928239637035617,
* // tkDiff: 0.034558548904317685,
* // cloudDistMod: 0.06339165670785038,
* // cloudThickMod: 0.011928239637035617,
* // tkDiffMod: 0.034558548904317685
* // }
* })
*
*/
let calcIchimoku = (() => {
//Ichimoku Cloud (一目均衡表)
//
//三層 lookback 嚴格按原版 Ichimoku 9 : 26 : 52 比例
//period label = SenkouB (最長 lookback) 對應的天數 (4hr K 線; SenkouB = period × 6 根)
//
//Tenkan = round(SenkouB × 9 / 52) (短期動能線)
//Kijun = SenkouB / 2 (中期趨勢線)
//SenkouB = period × 6 (最長 lookback, 雲頂/底候選之一)
//
//僅保留 4day~30day, 短於 4day 時 Tenkan 會 < 4 失意義 (e.g. 1day 對應 Tenkan=1)
//
//keyOut:
// cloudDist = (Close - cloudMid) / Close (Close 相對 cloud 中心偏離 %, 正=多/負=空)
// cloudThick = (cloudTop - cloudBot) / Close (雲厚 ratio, 支撐/壓力強度)
// tkDiff = (Tenkan - Kijun) / Close (TK 動能差 ratio)
//
//註: cloudDist 不用 (Close - cloudBot) / bandWidth (=「在雲內 0~1 位置」), 因 Kumo Twist
// 瞬間 bandWidth ≈ 0 會讓比值極端化 (實測 raw std=134, min/max 達 ±10000+).
// 改用 cloudMid 標準化的偏離 % 永遠穩定.
//
//Chikou (遲行線) 不存: 原版是 Close[i+26] 為 look-ahead, 回測不可用; [d13 處理為 chikou[i]=Close[i]] 失意義
let kp = {
'4day': { tenkan: 4, kijun: 12, senkouB: 24 },
'7day': { tenkan: 7, kijun: 21, senkouB: 42 },
'15day': { tenkan: 16, kijun: 45, senkouB: 90 },
'30day': { tenkan: 31, kijun: 90, senkouB: 180 },
}
//hhll: 取得 arr[startIdx..endIdx] (inclusive) 之 (max High + min Low) / 2
let hhll = (arr, startIdx, endIdx, kHigh, kLow) => {
let hi = -Infinity
let lo = Infinity
for (let j = startIdx; j <= endIdx; j++) {
let h = arr[j][kHigh]
let l = arr[j][kLow]
if (h > hi) {
hi = h
}
if (l < lo) {
lo = l
}
}
return (hi + lo) / 2
}
let caIchimoku = (arr, tenkan, kijun, senkouB, opt = {}) => {
//check
if (!isNumber(tenkan)) {
throw new Error(`tenkan is not a number`)
}
if (!isNumber(kijun)) {
throw new Error(`kijun is not a number`)
}
if (!isNumber(senkouB)) {
throw new Error(`senkouB is not a number`)
}
//plusClose: 偏移 close 為穩定分母 (cp = c + plusClose)
let plusClose = get(opt, 'plusClose', 0)
//plusCloudMid: 偏移 cloudMid 為穩定 cloudDist 分子
// cloudDistMod = (cp - cloudMidp) / cp = ((c-cloudMid) + (plusClose-plusCloudMid)) / cp
// 當 plusCloudMid = plusClose 時, 分子恢復原始 (c - cloudMid), 中心回到 0 而非 1
let plusCloudMid = get(opt, 'plusCloudMid', 0)
//plusBandWidth: 偏移 bandWidth 為穩定 cloudThick 分子 (bandWidth 本身 ≥0, 故只加不減)
let plusBandWidth = get(opt, 'plusBandWidth', 0)
//plusTenkans: 偏移 tenkan 與 kijun 為穩定 tkDiff 分子, 解決 t-k 雙峰分布
// t 與 k 同步偏移, 維持 (t-k) 之 sign, 但讓兩者基準遠離 0 → 比例變化變平緩
let plusTenkans = get(opt, 'plusTenkans', 0)
//n
let n = size(arr)
//startIdx: 第一個能算出全部 4 線之 index
// Tenkan/Kijun 直接算: 需 kijun-1 根之前
// SenkouA: 需 i-kijun 之 Tenkan/Kijun 都已存在 → i 至少 2*kijun-1
// SenkouB: 需 i-kijun 期回看 senkouB 根 → i 至少 kijun + senkouB - 1
let startIdx = Math.max(2 * kijun - 1, kijun + senkouB - 1)
if (n < startIdx + 1) {
return []
}
let kTime = 'time'
let kHigh = 'High'
let kLow = 'Low'
let kClose = 'Close'
//先 forward 算 Tenkan, Kijun 全序列
let tenkans = new Array(n).fill(null)
let kijuns = new Array(n).fill(null)
for (let i = 0; i < n; i++) {
if (i >= tenkan - 1) {
tenkans[i] = hhll(arr, i - tenkan + 1, i, kHigh, kLow)
}
if (i >= kijun - 1) {
kijuns[i] = hhll(arr, i - kijun + 1, i, kHigh, kLow)
}
}
//再 forward 算 SenkouA, SenkouB (含 forward shift: 用 i-kijun 期前的值前置到當下 i)
//SenkouA[i] = (Tenkan[i-kijun] + Kijun[i-kijun]) / 2
//SenkouB[i] = hhll(High/Low, i-kijun-senkouB+1, i-kijun)
let senkouAs = new Array(n).fill(null)
let senkouBs = new Array(n).fill(null)
for (let i = 0; i < n; i++) {
let srcIdx = i - kijun
if (srcIdx < 0) {
continue
}
let t = tenkans[srcIdx]
let k = kijuns[srcIdx]
if (t !== null && k !== null) {
senkouAs[i] = (t + k) / 2
}
if (srcIdx >= senkouB - 1) {
senkouBs[i] = hhll(arr, srcIdx - senkouB + 1, srcIdx, kHigh, kLow)
}
}
//rs
let rs = []
for (let i = startIdx; i < n; i++) {
let t = tenkans[i]
let k = kijuns[i]
let sa = senkouAs[i]
let sb = senkouBs[i]
let c = arr[i][kClose]
//check (理論上 i >= startIdx 後皆有值, 但仍保留 defensive 檢查)
if (t === null || k === null || sa === null || sb === null) {
continue
}
if (!isNumber(c)) {
throw new Error(`invalid c[${c}]`)
}
//cloud 上下邊界
let cloudTop = Math.max(sa, sb)
let cloudBot = Math.min(sa, sb)
let cloudMid = (cloudTop + cloudBot) / 2
let bandWidth = cloudTop - cloudBot
//cloudDist: Close 相對 cloudMid 偏離 % (正=多頭/負=空頭, 永遠穩定不爆炸)
let cloudDist = c !== 0 ? (c - cloudMid) / c : 0
//cloudThick: 雲厚相對 Close 之比例 (支撐/壓力強度)
let cloudThick = c !== 0 ? bandWidth / c : 0
//tkDiff: Tenkan vs Kijun 動能差 (TK Cross 連續版)
let tkDiff = c !== 0 ? (t - k) / c : 0
//*Mod = 對 close 加 plusClose 為穩定分母, 對 index OHLC (close 可 ≈0 或負) 修正除法爆炸
// plusClose=0 時退化成原版 (price 預設用法)
// plusClose>0 時把 close 偏移正方向, 解決 close ≈ 0 之 outlier
// diviProt 額外保護: |分母| < 0.00001 時 floor 至 ±0.00001 (保留 sign)
let cp = c + plusClose
let cloudMidp = cloudMid + plusCloudMid
let bandWidthp = bandWidth + plusBandWidth
let tenkansp = t + plusTenkans
let cloudDistMod = diviProt(cp - cloudMidp, cp)
let cloudThickMod = diviProt(bandWidthp, cp)
let tkDiffMod = diviProt(tenkansp - k, cp)
rs.push({
time: arr[i][kTime],
cloudDist,
cloudThick,
tkDiff,
cloudDistMod,
cloudThickMod,
tkDiffMod,
})
}
// console.log('rs', rs)
return rs
}
let caIchimokus = (arr, opt = {}) => {
//rrs
let rrs = []
each(kp, (cfg, period) => {
//caIchimoku
let rs = caIchimoku(arr, cfg.tenkan, cfg.kijun, cfg.senkouB, opt)
//push (用 senkouB 當 period 的代表 length, 對應最長 lookback)
rrs.push({
period,
len: cfg.senkouB,
vs: rs,
})
// console.log('rrs', rrs)
})
return rrs
}
let calcIchimoku = async(arr, key, opt = {}) => {
// arr = [
// {"time":"2020-01-01T00:00:00","Open":7195,"High":7225.62,"Low":7145.01,"Close":7173.32,...},
// ...
// ]
//caIchimokus
let rs = caIchimokus(arr, opt)
return rs
}
return calcIchimoku
})()
export default calcIchimoku