Files
Chan/web/static/js/app/chan_engine.js
T
jackyu66gitandCursor 9f1e7361b6 fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验
主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:09:48 +08:00

1395 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 缠论计算引擎 — 纯前端 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<gn-1, 取更低的 high)
*/
function _checkSBIInclusion(sbi, bi) {
var biHigh = Math.max(bi.p0, bi.p1)
var biLow = Math.min(bi.p0, bi.p1)
var included = false
if (sbi.high > 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 }
}