368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
// TradingView 自定义指标:缠论·笔 / 段 / 中枢 / 段中枢 / 笔BSP / 段BSP。
|
||
//
|
||
// 所有结构都在指标里:
|
||
// - 笔/段(实+虚 4 plot):line + line_with_breaks,按 bar 索引线性插值连成直线
|
||
// - 中枢 / 段中枢:上下沿 line plot + filled_area 填中间
|
||
// - BSP:chars plot 在 K 上下方显示文字标签('B'/'S'/'段B'/'段S')
|
||
//
|
||
// chan 数据从 chanLookupHolder 注入,上层切 symbol/freq 后换 holder.current 并重建 study。
|
||
|
||
import type { Bsp, ChanSlice } from '../api/types'
|
||
|
||
// 完全按 chan_core 原版 BSP 子类型打标:T1 / T1P / T2 / T2S / T3A / T3B
|
||
// 一个 BSP 的 types 可能同时包含多个子类(如 T1+T2),此时全部打上对应标签(重叠也接受)。
|
||
export const BSP_SUBTYPES = ['T1', 'T1P', 'T2', 'T2S', 'T3A', 'T3B'] as const
|
||
export type BspSubtype = typeof BSP_SUBTYPES[number]
|
||
|
||
type BspPlotKey =
|
||
| `bi_bsp_buy_${BspSubtype}` | `bi_bsp_sell_${BspSubtype}`
|
||
| `seg_bsp_buy_${BspSubtype}` | `seg_bsp_sell_${BspSubtype}`
|
||
|
||
// BarEntry:8 个线/中枢字段 + 24 个 BSP plot 字段(6 子类 × 买卖 × 笔/段)。
|
||
// 每个字段是 number?,非 undefined 表示该 bar 在该 plot 上有标签 / 数值。
|
||
type BarEntry = {
|
||
bi?: number; bi_pending?: number
|
||
seg?: number; seg_pending?: number
|
||
zs_top?: number; zs_bottom?: number
|
||
segzs_top?: number; segzs_bottom?: number
|
||
} & Partial<Record<BspPlotKey, number>>
|
||
|
||
export interface ChanLookup {
|
||
byTimeMs: Map<number, BarEntry>
|
||
}
|
||
|
||
// chanLookupHolder.key 标识当前 lookup 属于哪个 (exchange,symbol,freq) 组合。
|
||
// 同 key 时多次 refresh 累积合并(用户拖拽到新区域 → 旧区 BSP 标签仍保留);
|
||
// key 不同时(切 symbol/freq)整个清空。
|
||
export const chanLookupHolder: {
|
||
current: ChanLookup | null
|
||
key: string | null
|
||
} = { current: null, key: null }
|
||
|
||
/** 把 fresh lookup 累积/替换进 chanLookupHolder。同 key 累积,不同 key 替换。 */
|
||
export function commitChanLookup(fresh: ChanLookup, key: string): void {
|
||
if (chanLookupHolder.key !== key || !chanLookupHolder.current) {
|
||
chanLookupHolder.current = fresh
|
||
chanLookupHolder.key = key
|
||
return
|
||
}
|
||
// 同 key:合并 fresh 的 BarEntry 到现有 byTimeMs,让历史区间的 BSP 标签持续保留。
|
||
// 同一 bar 在两次 fetch 中都有数据时,新值覆盖旧值(chan 边界可能微调)。
|
||
const target = chanLookupHolder.current.byTimeMs
|
||
for (const [t, e] of fresh.byTimeMs) {
|
||
const existed = target.get(t)
|
||
if (existed) Object.assign(existed, e)
|
||
else target.set(t, e)
|
||
}
|
||
}
|
||
|
||
function lowerBound(arr: number[], v: number): number {
|
||
let lo = 0, hi = arr.length
|
||
while (lo < hi) {
|
||
const mid = (lo + hi) >> 1
|
||
if (arr[mid] < v) lo = mid + 1
|
||
else hi = mid
|
||
}
|
||
return lo
|
||
}
|
||
|
||
function upperBound(arr: number[], v: number): number {
|
||
let lo = 0, hi = arr.length
|
||
while (lo < hi) {
|
||
const mid = (lo + hi) >> 1
|
||
if (arr[mid] <= v) lo = mid + 1
|
||
else hi = mid
|
||
}
|
||
return lo
|
||
}
|
||
|
||
export function buildChanLookup(
|
||
slice: ChanSlice,
|
||
bars: { t: number; h: number; l: number }[],
|
||
): ChanLookup {
|
||
const byTimeMs = new Map<number, BarEntry>()
|
||
const ensure = (sec: number): BarEntry => {
|
||
const key = sec * 1000
|
||
let e = byTimeMs.get(key)
|
||
if (!e) {
|
||
e = {}
|
||
byTimeMs.set(key, e)
|
||
}
|
||
return e
|
||
}
|
||
|
||
const sortedBarTimes = bars.map((b) => b.t).sort((a, b) => a - b)
|
||
|
||
// 笔 / 段:按 bar 索引线性插值
|
||
const fillLine = (
|
||
t0: number, t1: number, p0: number, p1: number,
|
||
field: 'bi' | 'bi_pending' | 'seg' | 'seg_pending',
|
||
) => {
|
||
const lo = lowerBound(sortedBarTimes, t0)
|
||
const hi = upperBound(sortedBarTimes, t1)
|
||
const span = hi - 1 - lo
|
||
if (span <= 0) {
|
||
if (lo < sortedBarTimes.length) ensure(sortedBarTimes[lo])[field] = p0
|
||
return
|
||
}
|
||
const step = (p1 - p0) / span
|
||
for (let i = lo; i < hi; i++) {
|
||
ensure(sortedBarTimes[i])[field] = p0 + step * (i - lo)
|
||
}
|
||
}
|
||
|
||
for (const b of slice.bis) {
|
||
fillLine(b.t0, b.t1, b.p0, b.p1, b.sure ? 'bi' : 'bi_pending')
|
||
}
|
||
for (const s of slice.segs) {
|
||
fillLine(s.t0, s.t1, s.p0, s.p1, s.sure ? 'seg' : 'seg_pending')
|
||
}
|
||
|
||
// 中枢 / 段中枢:[t0, t1] 全段 K 都写 top/bottom;filled_area 自动填出矩形
|
||
const fillZs = (
|
||
t0: number, t1: number, high: number, low: number,
|
||
topField: 'zs_top' | 'segzs_top',
|
||
botField: 'zs_bottom' | 'segzs_bottom',
|
||
) => {
|
||
const lo = lowerBound(sortedBarTimes, t0)
|
||
const hi = upperBound(sortedBarTimes, t1)
|
||
for (let i = lo; i < hi; i++) {
|
||
const e = ensure(sortedBarTimes[i])
|
||
e[topField] = high
|
||
e[botField] = low
|
||
}
|
||
}
|
||
for (const z of slice.zs) fillZs(z.t0, z.t1, z.high, z.low, 'zs_top', 'zs_bottom')
|
||
for (const z of slice.segzs) fillZs(z.t0, z.t1, z.high, z.low, 'segzs_top', 'segzs_bottom')
|
||
|
||
// BSP:完全按原版子类型 T1/T1P/T2/T2S/T3A/T3B 打标,多 type 全部展示(重叠也接受)。
|
||
// 每个 BSP 遍历其 types 数组,对每个识别的子类型 mark 对应 plot。
|
||
const placeBsps = (list: Bsp[], prefix: 'bi_bsp' | 'seg_bsp') => {
|
||
for (const bsp of list) {
|
||
const dir = bsp.is_buy ? 'buy' : 'sell'
|
||
const e = ensure(bsp.t)
|
||
for (const raw of bsp.types ?? []) {
|
||
const t = String(raw).toUpperCase()
|
||
if (!(BSP_SUBTYPES as readonly string[]).includes(t)) continue
|
||
const key = `${prefix}_${dir}_${t as BspSubtype}` as BspPlotKey
|
||
e[key] = 1
|
||
}
|
||
}
|
||
}
|
||
placeBsps(slice.bsps, 'bi_bsp')
|
||
placeBsps(slice.seg_bsps ?? [], 'seg_bsp')
|
||
|
||
return { byTimeMs }
|
||
}
|
||
|
||
// 持久化:用户在指标设置对话框改的颜色保存到 localStorage,下次 makeChanIndicator
|
||
// 时把保存的值合并进 metainfo.defaults,新建 chan study 直接拿到用户的颜色。
|
||
// 按主题分 key(light/dark 互不影响)。不做颜色传播 —— 保存什么读什么。
|
||
function currentTheme(): 'light' | 'dark' {
|
||
try { return (localStorage.getItem('chart-theme') as 'light' | 'dark') ?? 'light' }
|
||
catch { return 'light' }
|
||
}
|
||
|
||
function chanStyleKey(): string {
|
||
return `chan-indicator-styles-v3-${currentTheme()}`
|
||
}
|
||
|
||
function loadSavedChanStyles(): {
|
||
styles?: Record<string, any>
|
||
filledAreasStyle?: Record<string, any>
|
||
} | null {
|
||
try {
|
||
const raw = localStorage.getItem(chanStyleKey())
|
||
return raw ? JSON.parse(raw) : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** TradingViewChart 在 study_properties_changed 时调用,把当前 getStyleValues()
|
||
* 原样写入 localStorage。下次刷新时 makeChanIndicator 会把保存值合并回 defaults。 */
|
||
export function saveChanStyles(sv: any): void {
|
||
try {
|
||
localStorage.setItem(chanStyleKey(), JSON.stringify({
|
||
styles: sv?.styles ?? {},
|
||
filledAreasStyle: sv?.filledAreasStyle ?? {},
|
||
}))
|
||
} catch {}
|
||
}
|
||
|
||
export function makeChanIndicator(): any {
|
||
const saved = loadSavedChanStyles()
|
||
const isDark = currentTheme() === 'dark'
|
||
// 按主题选笔/段基础色:暗色主题用白笔亮蓝段,亮色主题用黑笔深蓝段
|
||
const biColor = isDark ? '#ffffff' : '#000000'
|
||
const segColor = isDark ? '#42a5f5' : '#1565c0'
|
||
// 把 localStorage 里保存的字段叠在 base 之上 —— 用户改过的字段覆盖默认值
|
||
const mergeStyle = (id: string, base: Record<string, any>) => ({
|
||
...base,
|
||
...(saved?.styles?.[id] ?? {}),
|
||
})
|
||
const mergeFill = (id: string, base: Record<string, any>) => ({
|
||
...base,
|
||
...(saved?.filledAreasStyle?.[id] ?? {}),
|
||
})
|
||
return {
|
||
name: '缠论',
|
||
metainfo: {
|
||
_metainfoVersion: 53,
|
||
id: 'Chan@tv-basicstudies-1',
|
||
scriptIdPart: '',
|
||
description: 'Chan 缠论',
|
||
shortDescription: '缠论',
|
||
is_hidden_study: false,
|
||
isCustomIndicator: true,
|
||
is_price_study: true,
|
||
linkedToSeries: true,
|
||
format: { type: 'inherit' },
|
||
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' },
|
||
// 24 个 BSP chars plot:6 子类 × 买/卖 × 笔/段。
|
||
// 多 type 的 BSP 会同时在多个 plot 上打标(视觉上可能重叠,少见情况可接受)。
|
||
...BSP_SUBTYPES.flatMap((t) => [
|
||
{ id: `bi_bsp_buy_${t}`, type: 'chars' },
|
||
{ id: `bi_bsp_sell_${t}`, type: 'chars' },
|
||
{ id: `seg_bsp_buy_${t}`, type: 'chars' },
|
||
{ id: `seg_bsp_sell_${t}`, type: 'chars' },
|
||
]),
|
||
],
|
||
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: {
|
||
// 每个 style 都过一遍 mergeStyle/mergeFill —— 把 localStorage 里上次保存的同名字段
|
||
// 覆盖到内置默认值上,让用户编辑过的颜色/线宽跨浏览器刷新保留。
|
||
// 颜色按白色主题选:笔黑、段蓝、BSP 深色,保证在白底可见。
|
||
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: 1, plottype: 0, trackPrice: false,
|
||
transparency: 0, visible: true, color: biColor, 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: 3, plottype: 0, trackPrice: false,
|
||
transparency: 0, visible: true, color: segColor, display: 3 }),
|
||
// 中枢/段中枢上下沿:本身不画线,只作为 filled_area 的边界
|
||
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 chars plot:12 个 plot 对应 12 种文案。char 给空格占位(TV 仍认 chars plot
|
||
// 结构),实际标签由 styles.<plotId>.text 提供「T1/T1P/T2/T2S/T3A/T3B...」多字符文案。
|
||
// 24 个 BSP plot 默认:买红色 BelowBar,卖绿色 AboveBar。颜色由 char 默认 + styles
|
||
// text 提供。每个子类独立 plot,可在设置对话框单独调颜色。
|
||
...Object.fromEntries(BSP_SUBTYPES.flatMap((t) => [
|
||
[`bi_bsp_buy_${t}`, mergeStyle(`bi_bsp_buy_${t}`,
|
||
{ char: ' ', location: 'BelowBar', visible: true, size: 'large',
|
||
color: '#d32f2f', display: 3 })],
|
||
[`bi_bsp_sell_${t}`, mergeStyle(`bi_bsp_sell_${t}`,
|
||
{ char: ' ', location: 'AboveBar', visible: true, size: 'large',
|
||
color: '#2e7d32', display: 3 })],
|
||
[`seg_bsp_buy_${t}`, mergeStyle(`seg_bsp_buy_${t}`,
|
||
{ char: ' ', location: 'BelowBar', visible: true, size: 'large',
|
||
color: '#d32f2f', display: 3 })],
|
||
[`seg_bsp_sell_${t}`, mergeStyle(`seg_bsp_sell_${t}`,
|
||
{ char: ' ', location: 'AboveBar', visible: true, size: 'large',
|
||
color: '#2e7d32', display: 3 })],
|
||
])),
|
||
},
|
||
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,
|
||
// epoch 是个隐藏的整数 input:上层在 chanLookupHolder 更新后通过 setInputValues
|
||
// 改 epoch,逼 TV 重新跑 main() 重算所有 bar。这样 study 对象不需要被 remove+create
|
||
// 重建,用户改的颜色/线宽等样式自然保留。
|
||
inputs: { epoch: 0 },
|
||
},
|
||
styles: {
|
||
bi: { title: '笔', histogramBase: 0 },
|
||
bi_pending: { title: '笔(虚)', histogramBase: 0 },
|
||
seg: { title: '段', histogramBase: 0 },
|
||
seg_pending: { title: '段(虚)', histogramBase: 0 },
|
||
// 上下沿在设置对话框里也隐藏(仅作为 filled_area 边界,用户无需配置)
|
||
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 },
|
||
// 24 个 BSP plot 的标签:text 字段提供多字符渲染文案。子类型保留 chan_core 原版
|
||
// 后缀(1/1P/2/2S/3A/3B),但前缀用 B(买)/ S(卖)替换 T 来区分方向;段加「段」前缀。
|
||
// 设置对话框只暴露 2 项 —— 「买点」绑到 bi_bsp_buy_T1、「卖点」绑到 bi_bsp_sell_T1;
|
||
// 其他 22 个 plot 全部 isHidden 隐藏。改色后由 saveChanStyles 传播到所有 buy/sell
|
||
// 同方向 plot,刷新后所有标签同步换色。
|
||
...Object.fromEntries(BSP_SUBTYPES.flatMap((t) => {
|
||
const suffix = t.slice(1) // T1 → '1',T1P → '1P',T2S → '2S' 等
|
||
const buyText = `B${suffix}`
|
||
const sellText = `S${suffix}`
|
||
const isFirst = t === 'T1' // 用 T1 作为颜色源头暴露给设置对话框
|
||
return [
|
||
[`bi_bsp_buy_${t}`, {
|
||
title: isFirst ? '买点' : `B${suffix} 买点 (内部)`,
|
||
isHidden: !isFirst, text: buyText,
|
||
}],
|
||
[`bi_bsp_sell_${t}`, {
|
||
title: isFirst ? '卖点' : `S${suffix} 卖点 (内部)`,
|
||
isHidden: !isFirst, text: sellText,
|
||
}],
|
||
[`seg_bsp_buy_${t}`, { title: `段 ${buyText} 买点 (内部)`, isHidden: true, text: `段${buyText}` }],
|
||
[`seg_bsp_sell_${t}`, { title: `段 ${sellText} 卖点 (内部)`, isHidden: true, text: `段${sellText}` }],
|
||
]
|
||
})),
|
||
},
|
||
inputs: [
|
||
{ id: 'epoch', name: 'epoch', type: 'integer', defval: 0, isHidden: true },
|
||
],
|
||
},
|
||
constructor: function (this: any) {
|
||
this.init = function (this: any, context: any) {
|
||
this._context = context
|
||
}
|
||
this.main = function (this: any, context: any) {
|
||
this._context = context
|
||
// 8 个线/中枢 plot + 24 个 BSP chars plot(6 子类 × 买/卖 × 笔/段) = 32
|
||
// 顺序必须跟 plots 数组完全一致:先线/中枢 8 个,然后按 BSP_SUBTYPES 顺序展开
|
||
// 每个 subtype 的 [bi_buy, bi_sell, seg_buy, seg_sell] 共 4 个 plot。
|
||
const NANS = new Array(32).fill(NaN)
|
||
const lookup = chanLookupHolder.current
|
||
if (!lookup) return NANS
|
||
const t = context.symbol.time
|
||
const e = lookup.byTimeMs.get(t)
|
||
if (!e) return NANS
|
||
const out: number[] = [
|
||
e.bi ?? NaN, e.bi_pending ?? NaN,
|
||
e.seg ?? NaN, e.seg_pending ?? NaN,
|
||
e.zs_top ?? NaN, e.zs_bottom ?? NaN,
|
||
e.segzs_top ?? NaN, e.segzs_bottom ?? NaN,
|
||
]
|
||
for (const sub of BSP_SUBTYPES) {
|
||
out.push(
|
||
(e as any)[`bi_bsp_buy_${sub}`] ?? NaN,
|
||
(e as any)[`bi_bsp_sell_${sub}`] ?? NaN,
|
||
(e as any)[`seg_bsp_buy_${sub}`] ?? NaN,
|
||
(e as any)[`seg_bsp_sell_${sub}`] ?? NaN,
|
||
)
|
||
}
|
||
return out
|
||
}
|
||
},
|
||
}
|
||
}
|