/** * 缠论计算引擎 — 纯前端 JavaScript 实现 * * 严格参照 Python 实现 (TF_DF.py, ChanBI.py, ChanSBI.py) * 输入: OHLCV bar 数组 [{timestamp, open, high, low, close, volume}, ...] * 输出: ChanSlice 格式 {bis, segs, zs, segzs, bsps, seg_bsps} */ var MIN_BI_KLC = 3 // Python: bi_klc_min = 3 // ======================== 包含处理 (KLC) ======================== /** * 判断两根 K 线是否存在包含关系(a 完全包容 b) * a 包含 b: a.high >= b.high && a.low <= b.low */ function hasInclusion(a, b) { return a.high >= b.high && a.low <= b.low } /** * 包含处理 — 参照 Python get_klc_list() (TF_DF.py:637-655) * * 方向决定规则 (Python line 639): * last_klc.high < klu.high → UP, 否则 DOWN * 第一根 KLC: open <= close → UP, 否则 DOWN * * 合并规则 (Python ChanKLC.check_klu_included): * UP: 取 max(high), max(low) — 把 low 抬高 * DOWN: 取 min(high), min(low) — 把 high 压低 */ function inclusionMerge(bars) { if (bars.length < 2) return bars.map(function (b, i) { return { idx: i, dir: 0, fx: null, timestamp: b.timestamp, datetime: b.datetime, open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume } }) var klcList = [] // 第一根 KLC: 方向基于 open vs close (Python line 650-653) var first = bars[0] var firstDir = first.open <= first.close ? 1 : -1 klcList.push({ idx: 0, dir: firstDir, fx: null, timestamp: first.timestamp, datetime: first.datetime, open: first.open, high: first.high, low: first.low, close: first.close, volume: first.volume, _barCount: 1, }) for (var i = 1; i < bars.length; i++) { var lastKlc = klcList[klcList.length - 1] var cur = bars[i] if (hasInclusion(lastKlc, cur) || hasInclusion(cur, lastKlc)) { // 包含关系:按上一 KLC 的方向合并 var mergeDir = lastKlc.dir if (mergeDir === 1) { // UP: 取 max lastKlc.high = Math.max(lastKlc.high, cur.high) lastKlc.low = Math.max(lastKlc.low, cur.low) } else { // DOWN: 取 min lastKlc.high = Math.min(lastKlc.high, cur.high) lastKlc.low = Math.min(lastKlc.low, cur.low) } lastKlc.close = cur.close lastKlc.volume += cur.volume lastKlc.timestamp = cur.timestamp lastKlc.datetime = cur.datetime lastKlc._barCount = (lastKlc._barCount || 1) + 1 } else { // 无包含关系:创建新 KLC var newDir = cur.high > lastKlc.high ? 1 : -1 // Python line 639 klcList.push({ idx: klcList.length, dir: newDir, fx: null, timestamp: cur.timestamp, datetime: cur.datetime, open: cur.open, high: cur.high, low: cur.low, close: cur.close, volume: cur.volume, _barCount: 1, }) } } return klcList } // ======================== 分型检测 (保留,供外部使用) ======================== /** * 独立分型检测 — TF_DF.check_fx() 条件: * TOP: klc.high > pre.high && klc.high > next.high * && klc.low > pre.low && klc.low > next.low * BOTTOM: klc.low < pre.low && klc.low < next.low * && klc.high < pre.high && klc.high < next.high */ function findFractals(klcList) { if (klcList.length < 3) return [] var fractals = [] for (var i = 1; i < klcList.length - 1; i++) { var prev = klcList[i - 1], curr = klcList[i], next = klcList[i + 1] if (curr.low < prev.low && curr.low < next.low && curr.high < prev.high && curr.high < next.high) { fractals.push({ idx: i, klcIdx: curr.idx, type: 'BOTTOM', timestamp: curr.timestamp, datetime: curr.datetime, price: curr.low, high: curr.high, low: curr.low }) curr.fx = 'BOTTOM' } if (curr.high > prev.high && curr.high > next.high && curr.low > prev.low && curr.low > next.low) { fractals.push({ idx: i, klcIdx: curr.idx, type: 'TOP', timestamp: curr.timestamp, datetime: curr.datetime, price: curr.high, high: curr.high, low: curr.low }) curr.fx = 'TOP' } } return fractals } // ======================== 笔 (BI) 检测 ======================== /** * 缺口测试 — Python TF_DF.check_top_fx (line 1277-1280) * 当 last_bottom 的高点与候选顶分型两侧的低点重叠时,拒绝该顶分型 */ function checkTopFx(lastBottom, klc, pre, next) { if ((lastBottom.high > pre.low || lastBottom.high > next.low) && (klc.idx - lastBottom.idx < 100)) { return false } return true } /** * 缺口测试 — Python TF_DF.check_bottom_fx (line 1282-1285) */ function checkBottomFx(lastTop, klc, pre, next) { if ((lastTop.low < pre.high || lastTop.low < next.high) && (klc.idx - lastTop.idx < 100)) { return false } return true } /** * 将内部 bi 列表转换为输出 strokes 格式 */ function _biListToStrokes(biList) { var strokes = [] for (var i = 0; i < biList.length; i++) { var bi = biList[i] if (!bi._startKlc) continue var endKlc = bi._endKlc if (!endKlc && bi._klcList && bi._klcList.length > 0) { endKlc = bi._klcList[bi._klcList.length - 1] } // 末笔回退: 用 startKlc 作为终点 (至少显示一个点) if (!endKlc) { endKlc = bi._startKlc } var isUp = bi.dir === 'UP' strokes.push({ idx: strokes.length, t0: bi._startKlc.timestamp, t1: endKlc.timestamp, p0: isUp ? bi._startKlc.low : bi._startKlc.high, p1: isUp ? endKlc.high : endKlc.low, dir: isUp ? 1 : -1, sure: bi._isSure || false, startIdx: bi._startKlc.idx, endIdx: endKlc.idx, startType: isUp ? 'BOTTOM' : 'TOP', endType: isUp ? 'TOP' : 'BOTTOM', }) } return strokes } function _addKlcToLastBi(biList, klc) { if (biList.length > 0) { biList[biList.length - 1]._klcList.push(klc) } } /** * 笔检测 — 严格匹配 Python cal_bi_list() (TF_DF.py:946-1255) * * 遍历每一根 KLC,逐根检测分型 + 缺口测试 + 状态机处理。 * 使用 lastTop/lastBottom 追踪,在满足结合律时确认笔。 * * @param {Array} klcList — KLC 数组 * @returns {Array} strokes — 符合输出格式的笔列表 */ function findStrokes(klcList) { if (klcList.length < 3) return [] var biList = [] var lastTop = null // KLC 引用 var lastBottom = null // KLC 引用 for (var i = 1; i < klcList.length - 1; i++) { var klc = klcList[i] var pre = klcList[i - 1] var next = klcList[i + 1] // Step 1: 分型检测 — TF_DF.check_fx() var fx = null if (klc.high > pre.high && klc.high > next.high && klc.low > pre.low && klc.low > next.low) { fx = 'TOP' } else if (klc.low < pre.low && klc.low < next.low && klc.high < pre.high && klc.high < next.high) { fx = 'BOTTOM' } // Step 2: 缺口测试 — TF_DF.check_top_fx / check_bottom_fx if (fx === 'TOP' && lastBottom) { if (!checkTopFx(lastBottom, klc, pre, next)) { fx = null } } if (fx === 'BOTTOM' && lastTop) { if (!checkBottomFx(lastTop, klc, pre, next)) { fx = null } } // Step 3: 非分型 — 添加到当前笔 (Python line 967-971) if (!fx) { _addKlcToLastBi(biList, klc) continue } // 标记 KLC klc.fx = fx // === 处理顶分型 === if (fx === 'TOP') { if (lastTop) { if (lastBottom) { if (lastBottom.idx < lastTop.idx) { // 正常交替:底在前,顶在后 (Python line 1021-1037) if (lastTop.high > klc.high) { // 新顶更低 → 二类卖点,保持 lastTop _addKlcToLastBi(biList, klc) } else { // 新顶更高 → 替换 lastTop (一类卖点) lastTop = klc _addKlcToLastBi(biList, klc) } } else { // 不满足结合律:顶在前,底在后 (Python line 1038-1093) if (lastBottom.idx + MIN_BI_KLC > klc.idx) { // KLC 间距太小 (Python line 1042-1075) if (lastTop.high > klc.high) { _addKlcToLastBi(biList, klc) } else { // 无效顶分型 (Python line 1075) _addKlcToLastBi(biList, klc) } } else { // 满足结合律:确认上一个向下笔,开启新的向上笔 (Python line 1078-1093) var lastBi = biList[biList.length - 1] if (!lastBi._isSure) { lastBi._endKlc = lastBottom lastBi._isSure = true } var newBi = { dir: 'UP', _startKlc: lastBottom, _endKlc: null, _isSure: false, _klcList: [klc], idx: biList.length, } biList.push(newBi) lastTop = klc } } } else { // lastBottom 为空:初始化阶段 (Python line 1095-1109) if (lastTop.high < klc.high) { // 新顶更高 → 更新笔起点 var bi = biList[biList.length - 1] bi._startKlc = klc bi.dir = 'DOWN' lastTop = klc bi._klcList.push(klc) } else { _addKlcToLastBi(biList, klc) } } } else { // lastTop 为空 (Python line 1110-1133) if (lastBottom) { if (lastBottom.idx + MIN_BI_KLC > klc.idx) { // 间距太小 (Python line 1114-1118) _addKlcToLastBi(biList, klc) } else { // 第一个临时顶 (Python line 1120-1124) lastTop = klc _addKlcToLastBi(biList, klc) } } else { // 第一个分型 → 创建第一个向下笔 (Python line 1126-1133) lastTop = klc var bi = { dir: 'DOWN', _startKlc: klc, _endKlc: null, _isSure: false, _klcList: [klc], idx: 0, } biList.push(bi) } } } // === 处理底分型 (镜像) === else { if (lastBottom) { if (lastTop) { if (lastTop.idx < lastBottom.idx) { // 正常交替:顶在前,底在后 (Python line 1139-1155) if (lastBottom.low < klc.low) { // 新底更高 → 二类买点,保持 lastBottom _addKlcToLastBi(biList, klc) } else { // 新底更低 → 替换 lastBottom (一类买点) lastBottom = klc _addKlcToLastBi(biList, klc) } } else { // 不满足结合律:底在前,顶在后 (Python line 1157-1208) if (lastTop.idx + MIN_BI_KLC > klc.idx) { // KLC 间距太小 (Python line 1160-1190) if (lastBottom.low < klc.low) { _addKlcToLastBi(biList, klc) } else { // 无效底分型 (Python line 1190) _addKlcToLastBi(biList, klc) } } else { // 满足结合律:确认上一个向上笔,开启新的向下笔 (Python line 1192-1208) var lastBi = biList[biList.length - 1] if (!lastBi._isSure) { lastBi._endKlc = lastTop lastBi._isSure = true } var newBi = { dir: 'DOWN', _startKlc: lastTop, _endKlc: null, _isSure: false, _klcList: [klc], idx: biList.length, } biList.push(newBi) lastBottom = klc } } } else { // lastTop 为空:初始化阶段 (Python line 1210-1225) if (lastBottom.low > klc.low) { // 新底更低 → 更新笔起点 var bi = biList[biList.length - 1] bi._startKlc = klc bi.dir = 'UP' lastBottom = klc bi._klcList.push(klc) } else { _addKlcToLastBi(biList, klc) } } } else { // lastBottom 为空 (Python line 1227-1251) if (lastTop) { if (lastTop.idx + MIN_BI_KLC > klc.idx) { // 间距太小 (Python line 1230-1234) _addKlcToLastBi(biList, klc) } else { // 第一个临时底 (Python line 1236-1240) lastBottom = klc _addKlcToLastBi(biList, klc) } } else { // 第一个分型 → 创建第一个向上笔 (Python line 1242-1251) lastBottom = klc var bi = { dir: 'UP', _startKlc: klc, _endKlc: null, _isSure: false, _klcList: [klc], idx: 0, } biList.push(bi) } } } } return _biListToStrokes(biList) } // ======================== 线段 (SEG) 检测 ======================== /** * 线段检测 — 特征序列 (SBI) 算法 * * 严格参照 Python get_seg_list() (TF_DF.py:660-938) 和 ChanSBI.py * * 核心机制: * 1. 特征序列: UP 段取 DOWN 笔序列, DOWN 段取 UP 笔序列 * 2. SBI 包含处理: 特征序列元素间存在包含关系时合并 * 3. SBI 分型: 3+ 特征序列元素形成顶/底分型 → 段结束 * 4. 缺口: 分型无重叠时触发 look_for 标志,确认下一段 */ // ---- SBI (Special BI / 特征序列元素) ---- function _makeSBI(bi, idx) { return { startBi: bi, endBi: null, idx: idx, dir: bi.dir, high: Math.max(bi.p0, bi.p1), low: Math.min(bi.p0, bi.p1), pre: null, next: null, fx: null, // 'TOP' | 'BOTTOM' | null hasFxGap: false, biList: [bi], } } /** * SBI 包含检查 — 匹配 ChanSBI.check_bi_included() * * 条件: sbi.high > bi.high && sbi.low < bi.low (sbi 严格包容 bi) * 且 sbi 也包容 sbi.pre (双重确认) * * 包含时更新: * DOWN SBI (UP段特征序列): sbi.low = bi.low (gn>gn-1, 取更高的 low) * UP SBI (DOWN段特征序列): sbi.high = bi.high (gn biHigh && sbi.low < biLow) { included = true } if (included && sbi.pre) { // Python: pre check only reaffirms, never rejects // if self.high > self.pre.high and self.low < self.pre.low: included = True if (sbi.high > sbi.pre.high && sbi.low < sbi.pre.low) { included = true } } if (included) { sbi.biList.push(bi) if (sbi.dir === -1) { // DOWN SBI: gn > gn-1 → low 取 bi.low (更高的 low) sbi.low = biLow } else { // UP SBI: gn < gn-1 → high 取 bi.high (更低的 high) sbi.high = biHigh } } return included } /** * SBI 分型检测 — 匹配 ChanSBI.check_fx() * * 需要 pre, self, next 三个 SBI * TOP: self.high > pre.high && self.high > next.high * 缺口: self.low > self.pre.high * BOTTOM: self.low < pre.low && self.low < next.low * 缺口: self.high < self.pre.low */ function _checkSBIFx(sbi) { if (!sbi.pre || !sbi.next) return null if (sbi.high > sbi.pre.high && sbi.high > sbi.next.high) { sbi.fx = 'TOP' if (sbi.low > sbi.pre.high) { sbi.hasFxGap = true } return 'TOP' } if (sbi.low < sbi.pre.low && sbi.low < sbi.next.low) { sbi.fx = 'BOTTOM' if (sbi.high < sbi.pre.low) { sbi.hasFxGap = true } return 'BOTTOM' } return null } /** * 检查笔是否满足第一段起始条件 — 匹配 ChanBI.check_overlap() * bi 必须有 next 和 next.next 且都已确认 */ function _checkOverlap(bi) { if (!bi || !bi.next || !bi.next.next) return false // 简化: 笔已确认且有足够的后续笔即可形成第一段 // Python 原始条件要求 next.next.is_sure if (bi.dir === 1) { // UP bi: high > next.low && high < next.next.high var biHigh = Math.max(bi.p0, bi.p1) var nextLow = Math.min(bi.next.p0, bi.next.p1) var nextNextHigh = Math.max(bi.next.next.p0, bi.next.next.p1) return biHigh > nextLow && biHigh < nextNextHigh } else { // DOWN bi: high > next.high && low > next.next.low var biHigh = Math.max(bi.p0, bi.p1) var biLow = Math.min(bi.p0, bi.p1) var nextHigh = Math.max(bi.next.p0, bi.next.p1) var nextNextLow = Math.min(bi.next.next.p0, bi.next.next.p1) return biHigh > nextHigh && biLow > nextNextLow } } /** * 从 seg 对象生成输出格式 * p0/p1 使用起始笔和结束笔的价格 (匹配 Python: seg.low=start_bi.low, seg.high=end_bi.high) */ function _segToOutput(seg, idx) { var startBi = seg.startBi var endBi = seg.endBi || seg.biList[seg.biList.length - 1] if (!startBi || !endBi) { startBi = seg.biList[0] endBi = seg.biList[seg.biList.length - 1] } var p0, p1 if (seg.dir === 1) { // UP 段: p0 = startBi.low (起始底), p1 = endBi.high (结束顶) p0 = Math.min(startBi.p0, startBi.p1) p1 = Math.max(endBi.p0, endBi.p1) } else { // DOWN 段: p0 = startBi.high (起始顶), p1 = endBi.low (结束底) p0 = Math.max(startBi.p0, startBi.p1) p1 = Math.min(endBi.p0, endBi.p1) } return { idx: idx, dir: seg.dir, sure: seg.isSure || false, t0: startBi.t0, t1: endBi.t1, p0: p0, p1: p1, } } function findSegments(strokes) { if (strokes.length < 1) return [] // 建立双向链表 (Python bi.next / bi.pre) for (var i = 0; i < strokes.length; i++) { strokes[i].next = i + 1 < strokes.length ? strokes[i + 1] : null strokes[i].pre = i > 0 ? strokes[i - 1] : null } // 辅助: 填充 seg.biList 从 startBiIdx 到 currentBiIdx (匹配 Python ini_seg) function _fillSegBiList(seg, startIdx, endIdx) { seg.biList = [] for (var j = startIdx; j <= endIdx && j < strokes.length; j++) { seg.biList.push(strokes[j]) } } // 安全获取 stroke function _safeStroke(idx) { if (idx < 0) return strokes[0] if (idx >= strokes.length) return strokes[strokes.length - 1] return strokes[idx] } var segList = [] var upSbiList = [] var downSbiList = [] var lastUpBi = null var lastDownBi = null var lastUpSbi = null var lastDownSbi = null var lastSeg = null var lookForBottom = false var lookForTop = false for (var i = 0; i < strokes.length; i++) { var bi = strokes[i] if (segList.length > 0) { // ===== 已有段: 根据段方向处理 ===== if (lastSeg.dir === 1) { // ---- 当前是 UP 段,特征序列是 DOWN 笔 ---- if (bi.dir === -1) { // 反向笔 → 添加到特征序列 if (downSbiList.length > 1) { var included = _checkSBIInclusion(lastDownSbi, bi) if (!included) { var downSbi = _makeSBI(bi, downSbiList.length) lastDownSbi.next = downSbi lastDownSbi.endBi = lastDownBi downSbi.pre = lastDownSbi downSbiList.push(downSbi) var fx = _checkSBIFx(lastDownSbi) if (fx === 'TOP') { // 特征序列顶分型 → UP 段可能结束 if (lookForTop) { // 确认上一段(第二段前) if (segList.length >= 2) { segList[segList.length - 2].isSure = true } lookForTop = false } if (lastDownSbi.hasFxGap) { // 有缺口 → 段结束 + 新反向段开始 // Python pre_set_end_bi: 不设 is_sure, 等下一段确认 lookForBottom = true lastSeg.endBi = _safeStroke(lastDownSbi.startBi.idx - 1) // lastSeg.isSure stays false (gap segments are unsure until confirmed) var newSeg = { startBi: lastDownSbi.startBi, dir: -1, biList: [], isSure: false, endBi: bi, } _fillSegBiList(newSeg, lastDownSbi.startBi.idx, bi.idx) segList.push(newSeg) lastSeg.next = newSeg newSeg.pre = lastSeg lastSeg = newSeg // 重置 up_sbi_list upSbiList = [] lastUpSbi = _makeSBI(lastUpBi, 0) upSbiList.push(lastUpSbi) } else { // 无缺口 if (lookForBottom) { // Python: 不创建新段, 调整当前段起点, 关闭前一段 lookForBottom = false lastSeg.startBi = lastDownSbi.startBi if (segList.length >= 2) { segList[segList.length - 2].endBi = _safeStroke(lastDownSbi.startBi.idx - 1) // seg_list[-2].set_end_bi 会设 is_sure // 但需要检查 endBi 是否为 sure... 这里简化, set_end_bi 总是设 sure } upSbiList = [] lastUpSbi = _makeSBI(lastUpBi, 0) upSbiList.push(lastUpSbi) lastSeg.biList.push(bi) } else { // 正常段结束 — Python set_end_bi: 设 is_sure = True lastSeg.endBi = _safeStroke(lastDownSbi.startBi.idx - 1) lastSeg.isSure = true var newSeg = { startBi: lastDownSbi.startBi, dir: -1, biList: [], isSure: false, endBi: bi, } _fillSegBiList(newSeg, lastDownSbi.startBi.idx, bi.idx) segList.push(newSeg) lastSeg.next = newSeg newSeg.pre = lastSeg lastSeg = newSeg upSbiList = [] lastUpSbi = _makeSBI(lastUpBi, 0) upSbiList.push(lastUpSbi) } } } lastDownSbi = downSbi } lastSeg.biList.push(bi) } else if (downSbiList.length === 1) { var included = _checkSBIInclusion(lastDownSbi, bi) if (!included) { var downSbi = _makeSBI(bi, downSbiList.length) lastDownSbi.next = downSbi lastDownSbi.endBi = lastDownBi downSbi.pre = lastDownSbi downSbiList.push(downSbi) lastDownSbi = downSbi } lastSeg.biList.push(bi) } else { // downSbiList.length === 0 lastDownSbi = _makeSBI(bi, 0) downSbiList.push(lastDownSbi) lastSeg.biList.push(bi) } } else { // bi.dir === UP (同向笔) → 添加到 up_sbi_list if (lastUpSbi) { var included = _checkSBIInclusion(lastUpSbi, bi) if (!included) { var upSbi = _makeSBI(bi, upSbiList.length) lastUpSbi.next = upSbi lastUpSbi.endBi = lastUpBi upSbi.pre = lastUpSbi upSbiList.push(upSbi) lastUpSbi = upSbi } lastSeg.biList.push(bi) } } } else { // ---- 当前是 DOWN 段,特征序列是 UP 笔 ---- if (bi.dir === 1) { // 反向笔 → 添加到特征序列 if (upSbiList.length > 1) { var included = _checkSBIInclusion(lastUpSbi, bi) if (!included) { var upSbi = _makeSBI(bi, upSbiList.length) lastUpSbi.next = upSbi lastUpSbi.endBi = lastUpBi upSbi.pre = lastUpSbi upSbiList.push(upSbi) var fx = _checkSBIFx(lastUpSbi) if (fx === 'BOTTOM') { // 特征序列底分型 → DOWN 段可能结束 if (lookForBottom) { if (segList.length >= 2) { segList[segList.length - 2].isSure = true } lookForBottom = false } if (lastUpSbi.hasFxGap) { // 有缺口 → 段结束 + 新反向段开始 // Python pre_set_end_bi: 不设 is_sure, 等下一段确认 lookForTop = true lastSeg.endBi = _safeStroke(lastUpSbi.startBi.idx - 1) // lastSeg.isSure stays false (gap segments are unsure) var newSeg = { startBi: lastUpSbi.startBi, dir: 1, biList: [], isSure: false, endBi: bi, } _fillSegBiList(newSeg, lastUpSbi.startBi.idx, bi.idx) segList.push(newSeg) lastSeg.next = newSeg newSeg.pre = lastSeg lastSeg = newSeg // 重置 down_sbi_list downSbiList = [] lastDownSbi = _makeSBI(lastDownBi, 0) downSbiList.push(lastDownSbi) } else { // 无缺口 if (lookForTop) { // Python: 不创建新段, 调整当前段起点 lookForTop = false lastSeg.startBi = lastUpSbi.startBi if (segList.length >= 2) { segList[segList.length - 2].endBi = _safeStroke(lastUpSbi.startBi.idx - 1) } downSbiList = [] lastDownSbi = _makeSBI(lastDownBi, 0) downSbiList.push(lastDownSbi) lastSeg.biList.push(bi) } else { // 正常段结束 — Python set_end_bi: 设 is_sure = True lastSeg.endBi = _safeStroke(lastUpSbi.startBi.idx - 1) lastSeg.isSure = true var newSeg = { startBi: lastUpSbi.startBi, dir: 1, biList: [], isSure: false, endBi: bi, } _fillSegBiList(newSeg, lastUpSbi.startBi.idx, bi.idx) segList.push(newSeg) lastSeg.next = newSeg newSeg.pre = lastSeg lastSeg = newSeg downSbiList = [] lastDownSbi = _makeSBI(lastDownBi, 0) downSbiList.push(lastDownSbi) } } } lastUpSbi = upSbi } lastSeg.biList.push(bi) } else if (upSbiList.length === 1) { var included = _checkSBIInclusion(lastUpSbi, bi) if (!included) { var upSbi = _makeSBI(bi, upSbiList.length) lastUpSbi.next = upSbi lastUpSbi.endBi = lastUpBi upSbi.pre = lastUpSbi upSbiList.push(upSbi) lastUpSbi = upSbi } lastSeg.biList.push(bi) } else { // upSbiList.length === 0 lastUpSbi = _makeSBI(bi, 0) upSbiList.push(lastUpSbi) lastSeg.biList.push(bi) } } else { // bi.dir === DOWN (同向笔) → 添加到 down_sbi_list if (lastDownSbi) { var included = _checkSBIInclusion(lastDownSbi, bi) if (!included) { var downSbi = _makeSBI(bi, downSbiList.length) lastDownSbi.next = downSbi lastDownSbi.endBi = lastDownBi downSbi.pre = lastDownSbi downSbiList.push(downSbi) lastDownSbi = downSbi } lastSeg.biList.push(bi) } } } } else { // ===== 第一段: 需要 check_overlap ===== if (_checkOverlap(bi)) { if (bi.dir === 1) { var seg = { startBi: bi, dir: 1, biList: [], isSure: false, endBi: bi, } _fillSegBiList(seg, bi.idx, bi.idx) lastUpSbi = _makeSBI(bi, 0) upSbiList.push(lastUpSbi) segList.push(seg) lastSeg = seg } else { var seg = { startBi: bi, dir: -1, biList: [], isSure: false, endBi: bi, } _fillSegBiList(seg, bi.idx, bi.idx) lastDownSbi = _makeSBI(bi, 0) downSbiList.push(lastDownSbi) segList.push(seg) lastSeg = seg } } } // 更新 last bi 追踪 (Python line 880-885) if (bi.dir === 1) { lastUpBi = bi } else { lastDownBi = bi } } // Python: 不在此处标记 sure // - 正常结束: set_end_bi → isSure=true (已在上面设置) // - 跳空结束: pre_set_end_bi → isSure=false, 等 look_for 确认 // - 最后一段: 永远 unsure // 转换为输出格式 var result = [] for (var s = 0; s < segList.length; s++) { result.push(_segToOutput(segList[s], s)) } console.log('[SBI段] 总计:', result.length, '段 from', strokes.length, '笔') return result } // ======================== 中枢 (ZS) 检测 ======================== /** * 中枢 (Center/Pivot): 至少 3 段走势重叠的价格区间 * * ZG = min(各段的高点), ZD = max(各段的低点) * 条件: ZG > ZD * 后续段若仍在区间内,扩展中枢 */ /** * 笔中枢检测 — 从 stroke 列表按 3 笔重叠规则计算笔级别中枢 * * 参照 Python cal_bi_zs_list() (TF_DF.py:1295-1424): * - 从第4根笔开始(索引3),每3根确认笔为一组 * - 上涨中枢:bi1.dir=DOWN, bi2.dir=UP, bi3.dir=DOWN → zg=min(highs), zd=max(lows) * - 下跌中枢:bi1.dir=UP, bi2.dir=DOWN, bi3.dir=UP * - 后中枢不重叠前中枢:UP_ZS → zg > last.zg; DOWN_ZS → zd < last.zd * - 按两笔一组扩展到5根、7根... * - 追踪 GG/DD 与 ZG/ZD */ function findBiCenters(biList) { var zsList = [] if (biList.length < 3) return zsList var lastZs = null var startIdx = 3 while (startIdx + 2 < biList.length) { var bi1 = biList[startIdx] var bi2 = biList[startIdx + 1] var bi3 = biList[startIdx + 2] // 三笔必须全部确认 if (!(bi1.sure && bi2.sure && bi3.sure)) { startIdx++ continue } var h1 = Math.max(bi1.p0, bi1.p1), l1 = Math.min(bi1.p0, bi1.p1) var h2 = Math.max(bi2.p0, bi2.p1), l2 = Math.min(bi2.p0, bi2.p1) var h3 = Math.max(bi3.p0, bi3.p1), l3 = Math.min(bi3.p0, bi3.p1) var zg = Math.min(h1, h2, h3) var zd = Math.max(l1, l2, l3) if (zg <= zd) { startIdx++; continue } // 方向判定 var valid = false var zsDir = 0 // 1=UP, -1=DOWN if (!lastZs) { // 首个中枢 if (bi1.dir === -1 && bi2.dir === 1 && bi3.dir === -1) { zsDir = 1 // UP_ZS valid = true } else if (bi1.dir === 1 && bi2.dir === -1 && bi3.dir === 1) { zsDir = -1 // DOWN_ZS valid = true } } else { if (zg > lastZs.zg) { zsDir = 1 // UP_ZS valid = (bi1.dir === -1 && bi2.dir === 1 && bi3.dir === -1) } else if (zd < lastZs.zd) { zsDir = -1 // DOWN_ZS valid = (bi1.dir === 1 && bi2.dir === -1 && bi3.dir === 1) } } if (!valid) { startIdx++; continue } var gg = Math.max(h1, h2, h3) var dd = Math.min(l1, l2, l3) var biList_for_zs = [bi1, bi2, bi3] var endBiIdx = startIdx + 2 // 按两笔一组扩展 var addedAfterLeave = [] var leaveIdx = startIdx + 4 while (leaveIdx < biList.length) { var b = biList[leaveIdx] if (!b.sure) break if (Math.max(b.p0, b.p1) >= zd && Math.min(b.p0, b.p1) <= zg) { addedAfterLeave.push(biList[leaveIdx - 1]) addedAfterLeave.push(b) } else { break } leaveIdx += 2 } if (addedAfterLeave.length > 0) { biList_for_zs = biList_for_zs.concat(addedAfterLeave) var highs = biList_for_zs.map(function(bi) { return Math.max(bi.p0, bi.p1) }) var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) }) gg = Math.max.apply(null, highs) dd = Math.min.apply(null, lows) endBiIdx = startIdx + 2 + addedAfterLeave.length } var lastBiInCenter = biList_for_zs[biList_for_zs.length - 1] // 是否已离开中枢:之后出现完全在 ZG 之上或 ZD 之下的确认笔 → 中枢完成 var zsSure = false var lastInListIdx = -1 for (var li = 0; li < biList.length; li++) { if (biList[li] === lastBiInCenter || (biList[li].t0 === lastBiInCenter.t0 && biList[li].t1 === lastBiInCenter.t1)) { lastInListIdx = li break } } if (lastInListIdx < 0) lastInListIdx = endBiIdx for (var j = lastInListIdx + 1; j < biList.length; j++) { var leaveBi = biList[j] if (!leaveBi.sure) break var lbh = Math.max(leaveBi.p0, leaveBi.p1) var lbl = Math.min(leaveBi.p0, leaveBi.p1) if (lbl > zg || lbh < zd) { zsSure = true break } } var zs = { t0: bi1.t0, t1: lastBiInCenter.t1, high: zg, low: zd, zg: zg, zd: zd, gg: gg, dd: dd, is_sure: zsSure, bi_count: biList_for_zs.length, bi_list: biList_for_zs, start_bi_idx: startIdx, dir: zsDir, pre: lastZs, next: null, } if (lastZs) { lastZs.next = zs } zsList.push(zs) lastZs = zs startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0) } return zsList } /** * 段中枢检测 — 从 segment 列表按 3 段重叠规则计算段级别中枢 * * 参照 Python calculate_seg_zs() (TF_DF.py:1741+): * - 从第4段开始(索引3),每3段为一组 * - 上涨中枢:seg1.dir=DOWN, seg2.dir=UP, seg3.dir=DOWN * - 下跌中枢:seg1.dir=UP, seg2.dir=DOWN, seg3.dir=UP * - ZG = min(highs), ZD = max(lows),要求 ZG > ZD * - 后中枢与前中枢不重叠 * - 扩张/扩展检测 */ function findSegCenters(segs) { var zsList = [] if (segs.length < 3) return zsList var lastZs = null var startIdx = 3 while (startIdx + 2 < segs.length) { var s1 = segs[startIdx] var s2 = segs[startIdx + 1] var s3 = segs[startIdx + 2] if (!(s1.sure && s2.sure && s3.sure)) { startIdx++ continue } var h1 = Math.max(s1.p0, s1.p1), l1 = Math.min(s1.p0, s1.p1) var h2 = Math.max(s2.p0, s2.p1), l2 = Math.min(s2.p0, s2.p1) var h3 = Math.max(s3.p0, s3.p1), l3 = Math.min(s3.p0, s3.p1) var zg = Math.min(h1, h2, h3) var zd = Math.max(l1, l2, l3) if (zg <= zd) { startIdx++; continue } // 方向判定 var valid = false var zsDir = 0 if (!lastZs) { if (s1.dir === -1 && s2.dir === 1 && s3.dir === -1) { zsDir = 1; valid = true } else if (s1.dir === 1 && s2.dir === -1 && s3.dir === 1) { zsDir = -1; valid = true } } else { if (zg > lastZs.zg) { zsDir = 1 valid = (s1.dir === -1 && s2.dir === 1 && s3.dir === -1) } else if (zd < lastZs.zd) { zsDir = -1 valid = (s1.dir === 1 && s2.dir === -1 && s3.dir === 1) } } if (!valid) { startIdx++; continue } var gg = Math.max(h1, h2, h3) var dd = Math.min(l1, l2, l3) var endSegIdx = startIdx + 2 // 按两段一组扩展 var addedSegs = [] var extIdx = startIdx + 4 while (extIdx < segs.length) { var s = segs[extIdx] if (!s.sure) break if (Math.max(s.p0, s.p1) >= zd && Math.min(s.p0, s.p1) <= zg) { addedSegs.push(segs[extIdx - 1]) addedSegs.push(s) } else { break } extIdx += 2 } var segList_for_zs = [s1, s2, s3] if (addedSegs.length > 0) { segList_for_zs = segList_for_zs.concat(addedSegs) gg = Math.max.apply(null, segList_for_zs.map(function(s) { return Math.max(s.p0, s.p1) })) dd = Math.min.apply(null, segList_for_zs.map(function(s) { return Math.min(s.p0, s.p1) })) endSegIdx = startIdx + 2 + addedSegs.length } var lastSegInCenter = segList_for_zs[segList_for_zs.length - 1] var zsSure = false var lastSegListIdx = -1 for (var lsi = 0; lsi < segs.length; lsi++) { if (segs[lsi] === lastSegInCenter || (segs[lsi].t0 === lastSegInCenter.t0 && segs[lsi].t1 === lastSegInCenter.t1)) { lastSegListIdx = lsi break } } if (lastSegListIdx < 0) lastSegListIdx = endSegIdx for (var sj = lastSegListIdx + 1; sj < segs.length; sj++) { var leaveSeg = segs[sj] if (!leaveSeg.sure) break var lsh = Math.max(leaveSeg.p0, leaveSeg.p1) var lsl = Math.min(leaveSeg.p0, leaveSeg.p1) if (lsl > zg || lsh < zd) { zsSure = true break } } var zs = { t0: s1.t0, t1: lastSegInCenter.t1, high: zg, low: zd, zg: zg, zd: zd, gg: gg, dd: dd, is_sure: zsSure, seg_count: segList_for_zs.length, seg_list: segList_for_zs, start_seg_idx: startIdx, dir: zsDir, pre: lastZs, next: null, } if (lastZs) lastZs.next = zs zsList.push(zs) lastZs = zs startIdx = startIdx + 4 + (addedSegs.length > 0 ? addedSegs.length : 0) } return zsList } /** @deprecated — use findBiCenters + findSegCenters */ function findCenters(segs) { var segZss = findSegCenters(segs) return { zs: segZss, segzs: segZss } } // ======================== 买卖点 (BSP) 检测 ======================== /** * 缠论三类买卖点 —— 基于缠论原定义 * * 一买:向下离开中枢的笔结束点(低于 ZD),与进入笔形成盘整背驰 → 位于中枢下方 * 一卖:向上离开中枢的笔结束点(高于 ZG),与进入笔形成盘整背驰 → 位于中枢上方 * 二买:一买后反弹再回调,底点不低于一买 → 位于中枢下方 * 二卖:一卖后回调再反弹,顶点不高于一卖 → 位于中枢上方 * 三买:向上离开中枢后回拉,回拉底点不破 ZG → 位于中枢上方 * 三卖:向下离开中枢后反弹,反弹顶点不破 ZD → 位于中枢下方 */ function findBSP(strokes, biZsList, segZsList) { var bsps = [] var segBsps = [] var bspsSeen = {} var segBspsSeen = {} // ==================== 笔中枢买卖点 ==================== if (biZsList && biZsList.length > 0) { for (var zi = 0; zi < biZsList.length; zi++) { var zs = biZsList[zi] if (!zs.is_sure || !zs.bi_list || zs.bi_list.length < 3) continue // 中枢最后一笔在总笔列表中的索引 var lastZsBi = zs.bi_list[zs.bi_list.length - 1] var lastZsBiIdx = -1 for (var k = 0; k < strokes.length; k++) { if (strokes[k].t0 === lastZsBi.t0 && strokes[k].p0 === lastZsBi.p0) { lastZsBiIdx = k break } } if (lastZsBiIdx < 0) continue // — 确定离开笔:中枢完成后第一根突破中枢区间的笔 — var leaveBi = null var leaveBiIdx = -1 for (var j = lastZsBiIdx; j < strokes.length; j++) { var bi = strokes[j] if (!bi.sure) continue var biHigh = Math.max(bi.p0, bi.p1) var biLow = Math.min(bi.p0, bi.p1) // 向上离开:高点突破 ZG(位于中枢上方) if (bi.dir === 1 && biHigh > zs.zg) { leaveBi = bi; leaveBiIdx = j; break } // 向下离开:低点跌破 ZD(位于中枢下方) if (bi.dir === -1 && biLow < zs.zd) { leaveBi = bi; leaveBiIdx = j; break } } if (!leaveBi) continue var isUpLeave = leaveBi.dir === 1 // — 一类买卖点 — // 一卖:向上离开,结束点在中枢上方 // 一买:向下离开,结束点在中枢下方 if (isUpLeave) { var kS1 = leaveBi.t1 + '-bi-sell-T1P' if (!bspsSeen[kS1]) { bspsSeen[kS1] = true bsps.push({ t: leaveBi.t1, price: leaveBi.p1, is_buy: false, types: ['T1P'], reason: '一卖:向上离开中枢' }) } } else { var kB1 = leaveBi.t1 + '-bi-buy-T1' if (!bspsSeen[kB1]) { bspsSeen[kB1] = true bsps.push({ t: leaveBi.t1, price: leaveBi.p1, is_buy: true, types: ['T1'], reason: '一买:向下离开中枢' }) } } // — 离开后第一笔(回拉/反弹)— var nextBi = leaveBiIdx + 1 < strokes.length ? strokes[leaveBiIdx + 1] : null if (nextBi && nextBi.sure) { if (isUpLeave && nextBi.dir === -1) { // 向上离开后向下回拉 → 潜在三买(位于中枢上方) if (Math.min(nextBi.p0, nextBi.p1) >= zs.zg) { var kB3 = nextBi.t1 + '-bi-buy-T3A' if (!bspsSeen[kB3]) { bspsSeen[kB3] = true bsps.push({ t: nextBi.t1, price: nextBi.p1, is_buy: true, types: ['T3A'], reason: '三买:回拉不进中枢' }) } } } else if (!isUpLeave && nextBi.dir === 1) { // 向下离开后向上反弹 → 潜在三卖(位于中枢下方) if (Math.max(nextBi.p0, nextBi.p1) <= zs.zd) { var kS3 = nextBi.t1 + '-bi-sell-T3B' if (!bspsSeen[kS3]) { bspsSeen[kS3] = true bsps.push({ t: nextBi.t1, price: nextBi.p1, is_buy: false, types: ['T3B'], reason: '三卖:反弹不进中枢' }) } } } // — 离开后第二笔 → 二类买卖点 — var secondBi = leaveBiIdx + 2 < strokes.length ? strokes[leaveBiIdx + 2] : null if (secondBi && secondBi.sure) { if (isUpLeave) { // 一卖 → 回拉 → 再次向上:不创新高即二卖(位于中枢上方) if (secondBi.dir === 1 && Math.max(secondBi.p0, secondBi.p1) < Math.max(leaveBi.p0, leaveBi.p1)) { var kS2 = secondBi.t1 + '-bi-sell-T2S' if (!bspsSeen[kS2]) { bspsSeen[kS2] = true bsps.push({ t: secondBi.t1, price: secondBi.p1, is_buy: false, types: ['T2S'], reason: '二卖:反弹不创新高' }) } } } else { // 一买 → 反弹 → 再次向下:不创新低即二买(位于中枢下方) if (secondBi.dir === -1 && Math.min(secondBi.p0, secondBi.p1) > Math.min(leaveBi.p0, leaveBi.p1)) { var kB2 = secondBi.t1 + '-bi-buy-T2' if (!bspsSeen[kB2]) { bspsSeen[kB2] = true bsps.push({ t: secondBi.t1, price: secondBi.p1, is_buy: true, types: ['T2'], reason: '二买:回调不创新低' }) } } } } } } } // ==================== 段中枢买卖点 ==================== // 段中枢的买卖点同理笔中枢,使用段而非笔 if (segZsList && segZsList.length > 0) { for (var zj = 0; zj < segZsList.length; zj++) { var szs = segZsList[zj] if (!szs.is_sure || !szs.seg_list || szs.seg_list.length < 3) continue // 段中枢结束后第一笔突破中枢区间的笔 var sLastSeg = szs.seg_list[szs.seg_list.length - 1] var sLeaveBi = null for (var sj = 0; sj < strokes.length; sj++) { if (strokes[sj].t0 > sLastSeg.t1) { sLeaveBi = strokes[sj] break } } if (!sLeaveBi || !sLeaveBi.sure) continue var slHigh = Math.max(sLeaveBi.p0, sLeaveBi.p1) var slLow = Math.min(sLeaveBi.p0, sLeaveBi.p1) var sLeaves = (sLeaveBi.dir === 1 && slHigh > szs.zg) || (sLeaveBi.dir === -1 && slLow < szs.zd) if (!sLeaves) continue if (sLeaveBi.dir === 1) { var skS1 = sLeaveBi.t1 + '-seg-sell-T1P' if (!segBspsSeen[skS1]) { segBspsSeen[skS1] = true segBsps.push({ t: sLeaveBi.t1, price: sLeaveBi.p1, is_buy: false, types: ['T1P'], reason: '段一卖:向上离开段中枢' }) } } else { var skB1 = sLeaveBi.t1 + '-seg-buy-T1' if (!segBspsSeen[skB1]) { segBspsSeen[skB1] = true segBsps.push({ t: sLeaveBi.t1, price: sLeaveBi.p1, is_buy: true, types: ['T1'], reason: '段一买:向下离开段中枢' }) } } } } return { bsps: bsps, seg_bsps: segBsps } } // ======================== 主入口 ======================== /** * 计算缠论结构 * @param {Array} bars - OHLCV bar 数组 * @returns {Object} ChanSlice {bis, segs, zs, segzs, bsps, seg_bsps} */ function computeChan(bars) { if (!bars || bars.length < 10) { return { bis: [], segs: [], zs: [], segzs: [], bsps: [], seg_bsps: [] } } // Step 1: 包含处理 → KLC var klcList = inclusionMerge(bars) // Step 2+3: 笔检测(内部包含分型检测 + 缺口测试) var strokes = findStrokes(klcList) // Step 4: 线段检测 var segs = findSegments(strokes) // Step 5: 中枢检测 // 笔中枢:从笔列表计算(参照 cal_bi_zs_list) var bi_zs = findBiCenters(strokes) // 段中枢:从段列表计算(参照 calculate_seg_zs) var seg_zs = findSegCenters(segs) // Step 6: 买卖点检测(基于笔中枢和段中枢) var bspResult = findBSP(strokes, bi_zs, seg_zs) var bsps = bspResult.bsps var seg_bsps = bspResult.seg_bsps console.log('[缠论引擎] KLC:', klcList.length, '笔:', strokes.length, '段:', segs.length, '笔中枢:', bi_zs.length, '段中枢:', seg_zs.length, 'BSP:', bsps.length) return { bis: strokes, segs: segs, zs: bi_zs, segzs: seg_zs.length > 0 ? seg_zs : bi_zs, bsps: bsps, seg_bsps: seg_bsps, } } // Export if (typeof module !== 'undefined' && module.exports) { module.exports = { computeChan, inclusionMerge, findFractals, findStrokes, findSegments, findCenters, findBSP } }