/** * 缠论自定义指标 — TradingView Advanced Chart * * 从 chanIndicator.ts 转换为 vanilla JS。 * 在 K 线上叠加:笔/段(实线+虚线)、中枢(填色区域)、买卖点(文字标签)。 * * 依赖: * window.chanLookupHolder — 当前 Chan 结构数据 * window.commitChanLookup — 累积合并新数据 * window.makeChanIndicator — 创建 TV study 定义 */ (function () { 'use strict' // ---- BSP 子类型枚举 ---- var BSP_SUBTYPES = ['T1', 'T1P', 'T2', 'T2S', 'T3A', 'T3B'] // ---- 全局状态: chanLookupHolder ---- window.chanLookupHolder = { current: null, key: null, } /** * 累积/替换 chanLookup * 同 key 累积合并(历史区间的 BSP 标签持续保留) * 不同 key 整个替换 */ window.commitChanLookup = function (fresh, key) { var holder = window.chanLookupHolder if (holder.key !== key || !holder.current) { holder.current = fresh holder.key = key return } // 同 key 合并 var target = holder.current.byTimeMs fresh.byTimeMs.forEach(function (e, t) { var existed = target.get(t) if (existed) { Object.assign(existed, e) } else { target.set(t, e) } }) } // ---- 工具函数 ---- function lowerBound(arr, v) { var lo = 0, hi = arr.length while (lo < hi) { var mid = (lo + hi) >> 1 if (arr[mid] < v) lo = mid + 1 else hi = mid } return lo } function upperBound(arr, v) { var lo = 0, hi = arr.length while (lo < hi) { var mid = (lo + hi) >> 1 if (arr[mid] <= v) lo = mid + 1 else hi = mid } return lo } /** * 构建 ChanLookup:将 Chan 结构数据映射到每个 bar 的指标值 * * @param {Object} slice - ChanSlice {bis, segs, zs, segzs, bsps, seg_bsps} * @param {Array} bars - OHLCV bars [{t: ms, h, l}, ...] * @returns {Object} {byTimeMs: Map} */ window.buildChanLookup = function (slice, bars) { var byTimeMs = new Map() function ensure(tsMs) { // tsMs 已是毫秒(来自 data_provider 的 timestamp),无需再转换 var key = tsMs var e = byTimeMs.get(key) if (!e) { e = {} byTimeMs.set(key, e) } return e } var sortedBarTimes = bars.map(function (b) { return b.t }).sort(function (a, b) { return a - b }) // 线性插值填充笔/段到每个 bar function fillLine(t0, t1, p0, p1, field) { var lo = lowerBound(sortedBarTimes, t0) var hi = upperBound(sortedBarTimes, t1) var span = hi - 1 - lo if (span <= 0) { if (lo < sortedBarTimes.length) ensure(sortedBarTimes[lo])[field] = p0 return } var step = (p1 - p0) / span for (var i = lo; i < hi; i++) { ensure(sortedBarTimes[i])[field] = p0 + step * (i - lo) } } // 笔 if (slice.bis) { slice.bis.forEach(function (b) { fillLine(b.t0, b.t1, b.p0, b.p1, b.sure ? 'bi' : 'bi_pending') }) } // 段 if (slice.segs) { slice.segs.forEach(function (s) { fillLine(s.t0, s.t1, s.p0, s.p1, s.sure ? 'seg' : 'seg_pending') }) } // 中枢填充:区间内每根 bar 写入 top/bottom function fillZs(t0, t1, high, low, topField, botField) { var lo = lowerBound(sortedBarTimes, t0) var hi = upperBound(sortedBarTimes, t1) for (var i = lo; i < hi; i++) { var e = ensure(sortedBarTimes[i]) e[topField] = high e[botField] = low } } if (slice.zs) { slice.zs.forEach(function (z) { fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom') }) } if (slice.segzs) { slice.segzs.forEach(function (z) { fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom') }) } // BSP 买卖点标记 function placeBsps(list, prefix) { if (!list) return list.forEach(function (bsp) { var dir = bsp.is_buy ? 'buy' : 'sell' var e = ensure(bsp.t) var types = bsp.types || [] types.forEach(function (raw) { var t = String(raw).toUpperCase() if (BSP_SUBTYPES.indexOf(t) === -1) return var key = prefix + '_' + dir + '_' + t e[key] = 1 }) }) } placeBsps(slice.bsps, 'bi_bsp') placeBsps(slice.seg_bsps, 'seg_bsp') return { byTimeMs: byTimeMs } } // ---- 样式持久化 ---- function currentTheme() { try { return localStorage.getItem('chart-theme') || 'light' } catch (e) { return 'light' } } function chanStyleKey() { return 'chan-indicator-styles-v7-' + currentTheme() } function loadSavedChanStyles() { try { var raw = localStorage.getItem(chanStyleKey()) return raw ? JSON.parse(raw) : null } catch (e) { return null } } window.saveChanStyles = function (sv) { try { localStorage.setItem(chanStyleKey(), JSON.stringify({ styles: sv && sv.styles ? sv.styles : {}, filledAreasStyle: sv && sv.filledAreasStyle ? sv.filledAreasStyle : {}, })) } catch (e) { /* ignore */ } } // ---- 主体:创建 TV 自定义指标定义 ---- window.makeChanIndicator = function () { var saved = loadSavedChanStyles() var isDark = currentTheme() === 'dark' var biColor = isDark ? '#ffffff' : '#000000' var segColor = isDark ? '#42a5f5' : '#1565c0' function mergeStyle(id, base) { var savedStyle = (saved && saved.styles && saved.styles[id]) || {} var merged = {} var keys = Object.keys(base).concat(Object.keys(savedStyle)) keys.forEach(function (k) { if (k in savedStyle) merged[k] = savedStyle[k] else merged[k] = base[k] }) return merged } function mergeFill(id, base) { var savedFill = (saved && saved.filledAreasStyle && saved.filledAreasStyle[id]) || {} var merged = {} var keys = Object.keys(base).concat(Object.keys(savedFill)) keys.forEach(function (k) { if (k in savedFill) merged[k] = savedFill[k] else merged[k] = base[k] }) return merged } // 构建 plots 数组 var plots = [ { id: 'bi', type: 'line' }, { id: 'bi_pending', type: 'line' }, { id: 'seg', type: 'line' }, { id: 'seg_pending', type: 'line' }, { id: 'zs_top', type: 'line' }, { id: 'zs_bottom', type: 'line' }, { id: 'segzs_top', type: 'line' }, { id: 'segzs_bottom', type: 'line' }, ] BSP_SUBTYPES.forEach(function (t) { plots.push({ id: 'bi_bsp_buy_' + t, type: 'chars' }) plots.push({ id: 'bi_bsp_sell_' + t, type: 'chars' }) plots.push({ id: 'seg_bsp_buy_' + t, type: 'chars' }) plots.push({ id: 'seg_bsp_sell_' + t, type: 'chars' }) }) // 构建 styles 对象 // bi_pending/seg_pending: 虚线(linestyle:2),加粗 + 高亮色,确保末完成笔/段清晰可见 var pendingBiColor = isDark ? '#ff9800' : '#e65100' // orange var pendingSegColor = isDark ? '#e040fb' : '#aa00ff' // purple var styles = { bi: mergeStyle('bi', { linestyle: 0, linewidth: 1, plottype: 0, trackPrice: false, transparency: 0, visible: true, color: biColor, display: 3, }), bi_pending: mergeStyle('bi_pending', { linestyle: 2, linewidth: 2, plottype: 0, trackPrice: false, transparency: 0, visible: true, color: pendingBiColor, display: 3, }), seg: mergeStyle('seg', { linestyle: 0, linewidth: 3, plottype: 0, trackPrice: false, transparency: 0, visible: true, color: segColor, display: 3, }), seg_pending: mergeStyle('seg_pending', { linestyle: 2, linewidth: 4, plottype: 0, trackPrice: false, transparency: 0, visible: true, color: pendingSegColor, display: 3, }), zs_top: mergeStyle('zs_top', { linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, transparency: 100, visible: false, color: '#e4eaf1', display: 0, }), zs_bottom: mergeStyle('zs_bottom', { linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, transparency: 100, visible: false, color: '#1565c0', display: 0, }), segzs_top: mergeStyle('segzs_top', { linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, transparency: 100, visible: false, color: '#ef6c00', display: 0, }), segzs_bottom: mergeStyle('segzs_bottom', { linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, transparency: 100, visible: false, color: '#ef6c00', display: 0, }), } // BSP 样式 BSP_SUBTYPES.forEach(function (t) { styles['bi_bsp_buy_' + t] = mergeStyle('bi_bsp_buy_' + t, { char: '●', location: 'BelowBar', visible: true, size: 'large', color: '#d32f2f', display: 3, }) styles['bi_bsp_sell_' + t] = mergeStyle('bi_bsp_sell_' + t, { char: '●', location: 'AboveBar', visible: true, size: 'large', color: '#2e7d32', display: 3, }) styles['seg_bsp_buy_' + t] = mergeStyle('seg_bsp_buy_' + t, { char: '●', location: 'BelowBar', visible: true, size: 'large', color: '#d32f2f', display: 3, }) styles['seg_bsp_sell_' + t] = mergeStyle('seg_bsp_sell_' + t, { char: '●', location: 'AboveBar', visible: true, size: 'large', color: '#2e7d32', display: 3, }) }) // 构建 style titles var styleTitles = { bi: { title: '笔', histogramBase: 0 }, bi_pending: { title: '笔(虚)', histogramBase: 0 }, seg: { title: '段', histogramBase: 0 }, seg_pending: { title: '段(虚)', histogramBase: 0 }, zs_top: { title: '中枢上沿', histogramBase: 0, isHidden: true }, zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true }, segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true }, segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true }, } BSP_SUBTYPES.forEach(function (t) { // 类型名映射:T1/T2/T3A 是买点, T1P/T2S/T3B 是卖点 var typeInfo = { T1: { cls: '一', side: 'buy', num: '1' }, T1P: { cls: '一', side: 'sell', num: '1' }, T2: { cls: '二', side: 'buy', num: '2' }, T2S: { cls: '二', side: 'sell', num: '2' }, T3A: { cls: '三', side: 'buy', num: '3' }, T3B: { cls: '三', side: 'sell', num: '3' }, }[t] || { cls: '', side: '', num: '' } var buyText = 'B' + typeInfo.num var sellText = 'S' + typeInfo.num var isBuyType = typeInfo.side === 'buy' var isSellType = typeInfo.side === 'sell' // 笔中枢 BSP:全部可见 styleTitles['bi_bsp_buy_' + t] = { title: '笔·' + typeInfo.cls + '类买点', isHidden: !isBuyType, text: buyText, } styleTitles['bi_bsp_sell_' + t] = { title: '笔·' + typeInfo.cls + '类卖点', isHidden: !isSellType, text: sellText, } // 段中枢 BSP:只有一类买卖点有实际数据 var segBuyVisible = t === 'T1' var segSellVisible = t === 'T1P' styleTitles['seg_bsp_buy_' + t] = { title: '段·一类买点', isHidden: !segBuyVisible, text: '段B1', } styleTitles['seg_bsp_sell_' + t] = { title: '段·一类卖点', isHidden: !segSellVisible, text: '段S1', } }) return { name: '缠论', metainfo: { _metainfoVersion: 53, id: 'Chan@tv-basicstudies-5', scriptIdPart: '', description: 'Chan 缠论', shortDescription: '缠论', is_hidden_study: false, isCustomIndicator: true, is_price_study: true, linkedToSeries: true, format: { type: 'inherit' }, plots: plots, filledAreas: [ { id: 'zs_fill', objAId: 'zs_top', objBId: 'zs_bottom', type: 'plot_plot', title: '中枢', isHidden: false }, { id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot', title: '段中枢', isHidden: false }, ], defaults: { styles: styles, filledAreasStyle: { zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }), segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }), }, precision: 2, inputs: { epoch: 0 }, }, styles: styleTitles, inputs: [ { id: 'epoch', name: 'epoch', type: 'integer', defval: 0, isHidden: true }, ], }, constructor: function () { var self = this this.init = function (ctx) { self._context = ctx } this.main = function (context) { // 32 个 plot: 8 结构 + 24 BSP var NANS = new Array(32).fill(NaN) // v31: sniffing pass 时 context.symbol.time 为 NaN var t = context.symbol.time if (isNaN(t)) return NANS var lookup = window.chanLookupHolder.current if (!lookup) return NANS var e = lookup.byTimeMs.get(t) if (!e) return NANS var out = [ e.bi != null ? e.bi : NaN, e.bi_pending != null ? e.bi_pending : NaN, e.seg != null ? e.seg : NaN, e.seg_pending != null ? e.seg_pending : NaN, e.zs_top != null ? e.zs_top : NaN, e.zs_bottom != null ? e.zs_bottom : NaN, e.segzs_top != null ? e.segzs_top : NaN, e.segzs_bottom != null ? e.segzs_bottom : NaN, ] BSP_SUBTYPES.forEach(function (sub) { out.push( e['bi_bsp_buy_' + sub] != null ? e['bi_bsp_buy_' + sub] : NaN, e['bi_bsp_sell_' + sub] != null ? e['bi_bsp_sell_' + sub] : NaN, e['seg_bsp_buy_' + sub] != null ? e['seg_bsp_buy_' + sub] : NaN, e['seg_bsp_sell_' + sub] != null ? e['seg_bsp_sell_' + sub] : NaN ) }) return out } }, } } // ---- Epoch bump 机制 ---- var chanEpoch = 0 var CHAN_STUDY_DESC = 'Chan 缠论' /** * 确保缠论 study 存在并通过 epoch bump 触发重绘。 * 与 TradingViewChart.tsx 中 ensureAndPokeChanStudy 逻辑一致。 */ window.ensureAndPokeChanStudy = function (chart) { try { var studies = chart.getAllStudies ? chart.getAllStudies() : [] var existingId = null for (var i = 0; i < studies.length; i++) { if (studies[i].name === CHAN_STUDY_DESC) { existingId = studies[i].id break } } chanEpoch += 1 if (existingId) { try { var api = chart.getStudyById(existingId) if (api && api.setInputValues) { api.setInputValues([{ id: 'epoch', value: chanEpoch }]) } } catch (err) { console.warn('setInputValues Chan failed', err) } return } // 新建 study — 必须是 chart.createStudy(...) 保持 this 绑定! if (!chart.createStudy) return var result = chart.createStudy(CHAN_STUDY_DESC, false, false, { epoch: chanEpoch }) // createStudy 返回 Promise if (result && typeof result.then === 'function') { result.then(function (id) { if (!id) { console.warn('[缠论] createStudy 返回空 id(指标未注册成功)') return } console.log('[缠论] study 已创建', id) try { var studyApi = chart.getStudyById(id) if (studyApi && studyApi.bringToFront) studyApi.bringToFront() } catch (err) { console.warn('bringToFront Chan failed', err) } }).catch(function (err) { console.warn('createStudy Chan failed', err) }) } else if (result) { // 同步返回(兜底) console.log('[缠论] study 已创建 (sync)', result) } } catch (e) { console.error('ensureAndPokeChanStudy error', e) } } })()