Files
Chan/web/static/js/app/chan_indicator.js
T
jackyu66gitandCursor 74dec4e50b refactor: 缠论引擎包化与 Web 分层(ECR-001)
将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:48:20 +08:00

497 lines
16 KiB
JavaScript

/**
* 缠论自定义指标 — 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<ms, BarEntry>}
*/
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<string>
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)
}
}
})()