refactor: 缠论引擎包化与 Web 分层(ECR-001)

将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:48:20 +08:00
co-authored by Cursor
parent e2e45bc1bc
commit 74dec4e50b
160 changed files with 25576 additions and 23104 deletions
+367
View File
@@ -0,0 +1,367 @@
// 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
}
},
}
}