Files
Chan/web/static/js/chanIndicator.ts
T

368 lines
16 KiB
TypeScript
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.
// TradingView 自定义指标:缠论·笔 / 段 / 中枢 / 段中枢 / 笔BSP / 段BSP。
//
// 所有结构都在指标里:
// - 笔/段(实+虚 4 plot):line + line_with_breaks,按 bar 索引线性插值连成直线
// - 中枢 / 段中枢:上下沿 line plot + filled_area 填中间
// - BSPchars 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}`
// BarEntry8 个线/中枢字段 + 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/bottomfilled_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 直接拿到用户的颜色。
// 按主题分 keylight/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 plot6 子类 × 买/卖 × 笔/段。
// 多 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 plot12 个 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 plot6 子类 × 买/卖 × 笔/段) = 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
}
},
}
}