Indicator · Pine Script v6 · On the chart

缠论 - 分型与笔 [Chanlun Core]

by Ye-Yu-Mo

This indicator implements a full Chanlun pipeline on the price chart.

View on GitHub

GitHub stars11on the repository
Pine versionv6current
Lines of code2,204
Settings30inputs
LicenceGPL-3.0
Last update2 months ago5 Aug 2026

Add It to Your TradingView Chart

  1. 1Copy the scriptPress Copy script above, or the copy button on the code below.
  2. 2Open the Pine EditorOpen a TradingView chart and choose Pine Editor at the bottom of the screen.
  3. 3Paste over the templateCreate a new indicator, select all of the template text and paste. Press Save and give it a name.
  4. 4Add it to the chartPress Add to chart, then adjust the settings with the gear icon next to its name.

Source Code

2,204 lines of Pine Script v6 from chanlun_core.pine, published under GPL-3.0. Keep the author's credit and licence lines when you reuse it.

chanlun_core.pine
//@version=6
// 缠论核心 v1.2.0 - M1-M6: 笔构建重写(两阶段扫描)+ 全链路缠论
// 遵循禅师原文"教你炒股票"系列定义

indicator("缠论 - 分型与笔 [Chanlun Core]", "缠论核心", overlay=true, max_labels_count=500, max_lines_count=500, max_boxes_count=500)

// ============================================================
// ENUMS
// ============================================================

// @description 包含处理的趋势方向:向上(取高高+低高)或向下(取低低+高低)
enum TrendDirection
    UP          // 向上趋势,合并取高高+低高
    DOWN        // 向下趋势,合并取高低+低低

// @description 分型类型:顶分型 / 底分型 / 无分型(三态,"尚未成立"用 NONE 替代 na)
enum FractalType
    NONE        // 无分型(用于三态,替代 boolean na)
    TOP         // 顶分型
    BOTTOM      // 底分型

// @description 笔的方向:向上笔(底分型→顶分型,价格上涨)或向下笔(顶分型→底分型,价格下跌)
enum StrokeDirection
    UP          // 向上笔:底分型 → 顶分型
    DOWN        // 向下笔:顶分型 → 底分型

// ============================================================
// USER-DEFINED TYPES
// ============================================================

// @description 包含处理后的K线。一根逻辑K线可能对应多根原始K线
// @field originalIndex 对应原始K线序列的第一根索引
// @field mergedCount  合并了多少根原始K线(1 = 无合并)
// @field high         合并后的高点
// @field low          合并后的低点
// @field open         第一根原始K线的open
// @field close        最后一根原始K线的close
// @field direction    合并时的趋势方向 (TrendDirection enum 的 int 值)
type MergedBar
    int            originalIndex
    int            mergedCount
    float          high
    float          low
    float          open
    float          close
    TrendDirection direction

// @description 分型:缠论最基础的几何单元,所有上层结构(笔、线段、中枢)都从分型开始
// @field barIndex      分型在合并后K线序列中的索引
// @field originalIndex 对应的第一根原始K线索引(用于画图定位)
// @field price         顶分型 = 合并后K线的高点, 底分型 = 合并后K线的低点
// @field fType         分型方向: TOP or BOTTOM
type Fractal
    int         barIndex
    int         originalIndex
    float       price
    FractalType fType

// @description 笔:连接两个交替分型的线段,缠论趋势的基本单元
// @field startOrigIndex 起点对应原始K线 bar_index
// @field endOrigIndex   终点对应原始K线 bar_index
// @field startPrice     起点价格(底分型=low,顶分型=high)
// @field endPrice       终点价格
// @field dir            笔的方向 (UP=底→顶, DOWN=顶→底)
// @field barCount       跨越的原始K线数量(含两端分型K线)
// @field priceChange    价格变化幅度(绝对值)
// @field priceChangePct 价格变化百分比
type Stroke
    int             startOrigIndex
    int             endOrigIndex
    float           startPrice
    float           endPrice
    StrokeDirection dir
    int             barCount
    float           priceChange
    float           priceChangePct

// @description 中枢:至少三段连续笔的价格重叠区间,缠论的核心概念
// @field startStrokeIdx  起始笔在 strokes 数组中的索引
// @field endStrokeIdx    结束笔在 strokes 数组中的索引
// @field startOrigIndex  中枢起始对应的原始K线 bar_index(第一笔起点)
// @field endOrigIndex    中枢结束对应的原始K线 bar_index(最后一笔终点)
// @field zg              中枢高点 ZG = min(构成中枢各笔的高点)
// @field zd              中枢低点 ZD = max(构成中枢各笔的低点)
// @field gg              区间最高 GG = max(构成中枢各笔的高点)
// @field dd              区间最低 DD = min(构成中枢各笔的低点)
// @field strokeCount     构成中枢的笔数(≥3)
// @field startDir        进入中枢的方向(第一笔的方向)
// @field initialZG       中枢形成时(前三笔)的 ZG,延伸后不变——信号/预判/止损用
// @field initialZD       中枢形成时(前三笔)的 ZD,延伸后不变——信号/预判/止损用
type ZhongShu
    int             startStrokeIdx
    int             endStrokeIdx
    int             startOrigIndex
    int             endOrigIndex
    float           zg
    float           zd
    float           gg
    float           dd
    int             strokeCount
    StrokeDirection startDir
    float           initialZG  // 前三笔形成中枢时的 ZG,延伸后不变
    float           initialZD  // 前三笔形成中枢时的 ZD,延伸后不变

// @description 背驰类型:顶背驰(向上趋势末端)或底背驰(向下趋势末端)
enum BeiChiType
    NONE            // 无背驰
    TOP             // 顶背驰:价格新高,力度衰竭(向上趋势末端)
    BOTTOM          // 底背驰:价格新低,力度衰竭(向下趋势末端)

// @description 背驰信号:价格新高/新低但 MACD 力度衰竭
// @field strokeIndex   触发背驰的笔在 strokes 中的索引(离开段 b)
// @field refStrokeIndex 参考笔的索引(进入段 a)
// @field barIndex      背驰确认的原始 bar_index(离开段终点)
// @field currentArea   离开段 b 的 MACD 面积(绝对值)
// @field refArea       进入段 a 的 MACD 面积(绝对值)
// @field areaRatio     面积比 = currentArea / refArea(< 1 表示衰竭)
// @field bcType        顶背驰 or 底背驰
type BeiChiSignal
    int         strokeIndex
    int         refStrokeIndex
    int         barIndex
    float       currentArea
    float       refArea
    float       areaRatio
    BeiChiType  bcType

// @description 买卖点类型:B1/B2/B3(买点,绿色系)和 S1/S2/S3(卖点,红色系)
enum SignalType
    B1      // 第一类买点:下跌趋势底背驰后
    B2      // 第二类买点:B1 后回调不创新低
    B3      // 第三类买点:中枢上方回调不破 ZG
    S1      // 第一类卖点:上涨趋势顶背驰后(B1 的镜像)
    S2      // 第二类卖点:S1 后反弹不创新高(B2 的镜像)
    S3      // 第三类卖点:中枢下方反弹不破 ZD(B3 的镜像)

// @description 买卖点信号:结合中枢、背驰和笔的结构生成的交易信号
// @field barIndex    信号触发的原始 bar_index
// @field price       信号触发价格
// @field sType       信号类型 B1/B2/B3/S1/S2/S3
// @field refIndex    关联参考索引(B1/S1=趋势背驰stroke索引, B3/S3=中枢索引, B2/S2=B1/S1索引)
// @field description 信号描述文本(label 显示)
type TradingSignal
    int         barIndex
    float       price
    SignalType  sType
    int         refIndex
    string      description

// @description 实时预判条目:基于最新 M1-M5 状态的触发价格阈值计算
// @field signalType   预判的信号类型 "B1"/"B2"/"B3"/"S1"/"S2"/"S3"
// @field triggerPrice 触发价格阈值
// @field description  触发条件描述
// @field direction    价格需要突破的方向 ("UP"/"DOWN")
// @field statusText   预判生命周期状态:"⏳ 等待中" / "⏳ 观察背驰" / "⏳ 形成中" / "✅ 已触发" / "❌ 已失效"
type Prediction
    string signalType
    float  triggerPrice
    string description
    string direction
    string statusText
    float  stopLoss       // M3: 参考止损价格(na = 未计算)

// ============================================================
// INPUT PARAMETERS
// ============================================================

// 调试开关(默认关闭,测试用)
bool showContainmentDebug = input.bool(false, "显示包含处理过程", group="调试", tooltip="开启后高亮被合并的K线,显示合并前后对比")
bool showFractalLabels    = input.bool(true,  "显示分型文字标签",  group="调试", tooltip="显示顶/底分型的文字标签。关闭后只保留三角标记")
bool showBuildBiDebugInput = input.bool(false, "显示 buildBi 诊断日志", group="调试",
    tooltip="在 needFullRebuild 时打印 buildBi 两阶段扫描的诊断日志,用于验证笔构建逻辑。默认关闭,测试场景 A/B/C 时开启")

// --- 笔设置 (M2) ---
int   minBarsForBiInput     = input.int(5,    "笔最少K线数 (含分型)", minval=2, maxval=10, group="笔设置",
    tooltip="每笔至少跨越的处理后K线数(无包含关系),含两端分型K线。禅师原文要求≥5。ETF/分钟图可放宽至3-4")
float minBiPriceChangeInput = input.float(0.0, "笔最小价格变化 (%)", minval=0.0, maxval=20.0, step=0.1, group="笔设置",
    tooltip="过滤价格变化不足的噪音笔,0=不过滤。建议 A股个股 0.5-1.0%,ETF 0.2-0.5%")
bool  showBiLabelInput      = input.bool(false, "显示笔信息标签", group="笔设置",
    tooltip="在每笔终点显示K线数和价格变化百分比")

// --- 中枢设置 (M3) ---
int   minStrokesForZSInput  = input.int(3,    "中枢最少笔数", minval=3, maxval=10, group="中枢设置",
    tooltip="构成中枢至少需要的笔数,禅师原文=3")
float minZSWidthInput       = input.float(0.0, "中枢最小宽度 (%)", minval=0.0, maxval=10.0, step=0.1, group="中枢设置",
    tooltip="过滤掉 ZG-ZD 过窄的伪中枢,0=不过滤。建议 0.1-0.3%")
bool  showZSLevelInput      = input.bool(false, "显示中枢级别", group="中枢设置",
    tooltip="在中枢框上标注包含的笔数")

// --- MACD 设置 (M4) ---
int  macdFastInput     = input.int(12, "MACD 快线", minval=2, maxval=50, group="MACD / 背驰")
int  macdSlowInput     = input.int(26, "MACD 慢线", minval=5, maxval=100, group="MACD / 背驰")
int  macdSignalInput   = input.int(9,  "MACD 信号线", minval=2, maxval=50, group="MACD / 背驰")
bool macdAbsoluteInput = input.bool(false, "MACD 绝对值面积", group="MACD / 背驰",
    tooltip="开启=取MACD柱的绝对值面积比较;关闭=保留正负符号")

// --- 背驰设置 (M4) ---
float areaRatioThresholdInput = input.float(0.8, "背驰面积比阈值", minval=0.1, maxval=1.0, step=0.05, group="MACD / 背驰",
    tooltip="当前笔面积/参考笔面积 < 此值 = 背驰。0.8=衰竭到原来的80%以下")
bool  showBeiChiInput         = input.bool(true, "显示背驰信号", group="MACD / 背驰")
bool  showConsolidationBCInput = input.bool(false, "显示盘整背驰", group="MACD / 背驰",
    tooltip="盘整背驰信号较多,默认关闭以减少图表干扰")

// --- 买卖点设置 (M5) ---
bool showBuySignalsInput  = input.bool(true, "显示买点 (B1/B2/B3)", group="买卖点设置")
bool showSellSignalsInput = input.bool(true, "显示卖点 (S1/S2/S3)", group="买卖点设置")
int  minSignalBarsInput   = input.int(10, "同类型信号最小间距 (K线)", minval=1, maxval=500, group="买卖点设置",
    tooltip="两个同类型信号之间的最少K线数,避免信号过密。1=不过滤,500=极度稀疏")
bool showB2Input          = input.bool(true, "显示第二类买卖点 (B2/S2)", group="买卖点设置")
bool showB3Input          = input.bool(true, "显示第三类买卖点 (B3/S3)", group="买卖点设置")

// --- 预判设置 (M6) ---
bool showPredictionInput = input.bool(true, "显示预判面板", group="预判设置",
    tooltip="在图表右上角显示实时预判表格,仅在盘中实时K线时更新")
float alertThresholdInput = input.float(0.5, "告警价格偏差 (%)", minval=0.1, maxval=5.0, step=0.1, group="预判设置",
    tooltip="当前价与触发价的偏差小于此值时触发告警")

// --- 止损设置 (M3) ---
bool  showStopLossInput           = input.bool(true,  "显示止损信息", group="止损设置",
    tooltip="在信号标签和预判面板中显示参考止损位。关闭后与 v0.8.0 行为一致")
int   stopLossAtrPeriodInput      = input.int(14,   "ATR 周期", minval=5, maxval=50, group="止损设置",
    tooltip="用于计算动态止损距离的 ATR 周期。14=约三周日线波动率")
float stopLossAtrMultiplierInput  = input.float(1.5, "ATR 倍数", minval=0.5, maxval=5.0, step=0.1, group="止损设置",
    tooltip="止损距离 = max(ATR×倍数, 价格×2%)。倍数越大止损越宽")

// --- 信号过滤设置 (M4) ---
bool showSignalQualityInput = input.bool(true, "显示信号质量评分", group="信号过滤",
    tooltip="在信号标签中显示 ★ 质量评分(0-100)。关闭后标签退化为 M3 样式")
int  minSignalQualityInput  = input.int(0,   "最低信号质量", minval=0, maxval=100, group="信号过滤",
    tooltip="只显示评分 ≥ 此值的信号。0=不过滤,80=仅高质量信号")
bool showMATrendFilterInput = input.bool(false, "启用 MA 趋势过滤", group="信号过滤",
    tooltip="价格在 MA 上方只显示买点、下方只显示卖点。被过滤信号以灰色显示")
int  maTrendPeriodInput     = input.int(60,  "MA 趋势周期", minval=20, maxval=250, group="信号过滤",
    tooltip="均线周期。仅在\"启用 MA 趋势过滤\"时生效")

// ATR 系列(M3 止损计算用,全局作用域,函数内可直接引用)
float atrCurrent = ta.atr(stopLossAtrPeriodInput)

// MA 趋势值(M4 信号过滤用,全局作用域,函数内可直接引用)
float maTrendValue = ta.sma(close, maTrendPeriodInput)

// ============================================================
// 功能1: 包含判断 - hasContainment()
// ============================================================

// @function  判断两根K线是否有包含关系
// @param     bar1 MergedBar - 前一根K线
// @param     bar2 MergedBar - 后一根K线
// @returns   bool - true = 有包含关系(一根完全包裹另一根,含等号边界)
//
// 包含关系定义(禅师原文):
// - bar1 包含 bar2: bar1.high >= bar2.high AND bar1.low <= bar2.low
// - bar2 包含 bar1: bar2.high >= bar1.high AND bar2.low <= bar1.low
// - 非包含:两根K线的高低点有交叉而不完全包裹
hasContainment(bar1, bar2) =>
    (bar1.high >= bar2.high and bar1.low <= bar2.low) or (bar2.high >= bar1.high and bar2.low <= bar1.low)

// ============================================================
// 功能2: 趋势方向判断 - getTrendDirection()
// ============================================================

// @function  判断包含关系的合并方向
// @param     prevBar MergedBar - 前一根非包含K线(用于判断趋势)
// @param     currBar MergedBar - 当前K线(与前一根有包含关系)
// @returns   TrendDirection - UP 或 DOWN
//
// 规则(禅师原文第65课):
// - 如果前一根非包含K线的低点 < 当前K线的低点 → 向上趋势(序列向上)
// - 如果前一根非包含K线的高点 > 当前K线的高点 → 向下趋势(序列向下)
// - 默认:无法判断时按向上处理
getTrendDirection(prevBar, currBar) =>
    if prevBar.low < currBar.low
        TrendDirection.UP
    else if prevBar.high > currBar.high
        TrendDirection.DOWN
    else
        // 无法判断时默认向上
        TrendDirection.UP

// ============================================================
// 功能3: 单次合并 - mergeTwoBars()
// ============================================================

// @function  合并两根有包含关系的K线
// @param     bar1 MergedBar - 前一根K线(时间在先)
// @param     bar2 MergedBar - 后一根K线(时间在后)
// @param     direction TrendDirection - 合并方向
// @returns   MergedBar - 合并后的K线
//
// 核心规则(一个条件分支,不是四个):
// - UP:   high = max(h1, h2), low = max(l1, l2)  ← "取高高、取低高"
// - DOWN: high = min(h1, h2), low = min(l1, l2)  ← "取低低、取高低"
// - open 始终取 bar1.open(时间在先),close 始终取 bar2.close(时间在后)
mergeTwoBars(bar1, bar2, direction) =>
    mergedHigh = direction == TrendDirection.UP ? math.max(bar1.high, bar2.high) : math.min(bar1.high, bar2.high)
    mergedLow  = direction == TrendDirection.UP ? math.max(bar1.low, bar2.low)  : math.min(bar1.low, bar2.low)

    MergedBar.new(
      bar1.originalIndex,
      bar1.mergedCount + bar2.mergedCount,
      mergedHigh,
      mergedLow,
      bar1.open,
      bar2.close,
      direction
    )

// ============================================================
// 功能4: 主包含处理循环 - processContainment()
// ============================================================

// @function  对原始K线序列做包含处理(批量版本,符合TODO-M1规范)
// @param     bars array<MergedBar> - 原始K线数组(初始 mergedCount=1)
// @returns   array<MergedBar> - 包含处理后的K线数组(无相邻包含关系)
//
// 算法(一趟扫描 + 回溯):
// 1. 从左到右遍历 bars
// 2. 对于 bars[i] 和 bars[i-1]:
//    a. 无包含关系 → i++,继续下一根
//    b. 有包含关系 → 用 bars[i-2] 和 bars[i-1] 判断趋势方向
//       - 合并 bars[i-1] 和 bars[i] 为 newBar
//       - 把 newBar 放回位置 i-1,删除位置 i
//       - 回退 i(i--),检查 newBar 与 bars[i-2] 是否有新包含关系
// 3. 直到 i 遍历完所有 bars
//
// 时间复杂度:O(n) —— 每根K线最多被合并一次
// 注意:此函数会修改传入的数组(Pine Script 数组是引用传递)
processContainment(bars) =>
    if bars.size() < 2
        bars

    i = 1
    while i < bars.size()
        prev = bars.get(i - 1)
        curr = bars.get(i)

        if hasContainment(prev, curr)
            // 用再前一根非包含K线判断趋势方向
            dir = TrendDirection.UP
            if i >= 2
                prevPrev = bars.get(i - 2)
                dir := getTrendDirection(prevPrev, prev)

            // 合并并原地替换
            merged = mergeTwoBars(prev, curr, dir)
            bars.set(i - 1, merged)
            bars.remove(i)

            // 回退一步检查连锁包含(合并后的新K线可能与再前一根产生新包含)
            if i > 1
                i := i - 1
        else
            i := i + 1

    bars

// ============================================================
// 功能2a: 分型检测 - identifyFractals()
// ============================================================

// @function  在包含处理后的K线序列上识别顶分型和底分型
// @param     mergedBars array<MergedBar> - 包含处理后的K线数组
// @returns   array<Fractal> - 识别到的所有分型(未过滤交替)
//
// 顶分型条件(禅师原文,三个条件同时满足):
//   1. bar[i-1].high > bar[i-2].high AND bar[i-1].high > bar[i].high  (中间高点是最高)
//   2. bar[i-1].low  > bar[i-2].low  AND bar[i-1].low  > bar[i].low   (中间低点也是最高)
//   3. bar[i-1] 与前后均无包含关系(已由包含处理保证)
//
// 底分型条件(三个条件同时满足):
//   1. bar[i-1].low  < bar[i-2].low  AND bar[i-1].low  < bar[i].low   (中间低点是最低)
//   2. bar[i-1].high < bar[i-2].high AND bar[i-1].high < bar[i].high  (中间高点也是最低)
//   3. bar[i-1] 与前后均无包含关系(已由包含处理保证)
//
// 时间复杂度:O(n),一趟扫描
identifyFractals(mergedBars) =>
    fractals = array.new<Fractal>()
    size = mergedBars.size()
    if size >= 3
        for i = 2 to size - 1
            barLeft  = mergedBars.get(i - 2)
            barMid   = mergedBars.get(i - 1)
            barRight = mergedBars.get(i)

            // 顶分型:中间K线的高点和低点都是三根中最高的
            if barMid.high > barLeft.high and barMid.high > barRight.high and
               barMid.low  > barLeft.low  and barMid.low  > barRight.low
                fractals.push(Fractal.new(i - 1, barMid.originalIndex, barMid.high, FractalType.TOP))

            // 底分型:中间K线的低点和高点都是三根中最低的
            if barMid.low  < barLeft.low  and barMid.low  < barRight.low  and
               barMid.high < barLeft.high and barMid.high < barRight.high
                fractals.push(Fractal.new(i - 1, barMid.originalIndex, barMid.low, FractalType.BOTTOM))

    fractals

// ============================================================
// 功能3: 笔构建 - buildBi()
// ============================================================

// @function  从原始分型序列构建笔序列(两阶段扫描:定起点 + 定终点)
// @param     rawFractals array<Fractal> - 原始分型数组(未经交替过滤)
// @param     minMergedBars int - 每笔至少跨越的处理后K线数
// @param     minPriceChangePct float - 最小价格变化百分比(0 = 不过滤)
// @returns   array<Stroke> - 笔数组
//
// 间距计算:直接使用分型的 barIndex(合并后 K 线索引)相减。
// barIndex 由 identifyFractals() 在创建分型时从 mergedBars 的索引赋值,
// 天然是合并后 K 线位置,无需映射表或额外传参。
//
// 两阶段算法(修正单趟扫描的"起点动态竞争"缺陷):
//   阶段1 定起点:连续替换更极端的同向分型,直到第一个反向分型为止
//   阶段2 定终点:从起点之后第一个反向分型起,选评分最高的合格终点,
//                 遇到同向分型即终止(终点不能越过同向分型)
//
// 关键语义:起点一旦确定(阶段1结束),bestStart 不再变化。
// 这消除了两个单趟扫描的缺陷:
//   1. 时间倒置笔——单趟中 bestStart 被替换后 bestEnd 未重置,可能产生
//      startOrigIndex > endOrigIndex 的反向笔
//   2. 场景 C 无法实现——单趟中 B1 被更极端的 B2"取代"而非"跳过",
//      T1→B2 永远无法成为笔
//
// 已成之笔绝不因后续更极端的同向分型被回溯删除。
// B1(bar5)→T1(bar10,合格)→B2(bar15,更极端)→T2(bar25) 输出 3 笔
// (B1→T1、T1→B2、B2→T2)——B2 更低不否定 B1→T1,它只是下一笔的起点。
//
// 时间复杂度:O(n²),n = 原始分型数
buildBi(rawFractals, minMergedBars, minPriceChangePct) =>
    strokes = array.new<Stroke>()
    n = rawFractals.size()
    if n < 2
        strokes

    if showBuildBiDebugInput
        log.info("[buildBi] n={0} minMergedBars={1}", n, minMergedBars)

    roundIdx = 0
    startIdx = 0
    while startIdx < n - 1
        if showBuildBiDebugInput
            log.info("[buildBi] round#{0} startIdx={1}", roundIdx, startIdx)

        // 阶段1:确定起点——连续替换更极端的同向分型,直到第一个反向分型
        bestStart = rawFractals.get(startIdx)
        if showBuildBiDebugInput
            log.info("[buildBi]   phase1: start=bar{0}({1}@{2,number,#.#})", bestStart.barIndex, (bestStart.fType == FractalType.TOP ? "TOP" : "BOTTOM"), bestStart.price)
        j = startIdx + 1
        while j < n
            currF = rawFractals.get(j)
            if currF.fType == bestStart.fType
                isMoreExtreme = ((currF.fType == FractalType.TOP and currF.price > bestStart.price) or
                                 (currF.fType == FractalType.BOTTOM and currF.price < bestStart.price))
                if isMoreExtreme
                    bestStart := currF
                    if showBuildBiDebugInput
                        log.info("[buildBi]   phase1: bar{0}({1}@{2,number,#.#}) 同向更极端 → 替换起点", currF.barIndex, (currF.fType == FractalType.TOP ? "TOP" : "BOTTOM"), currF.price)
                j := j + 1
            else
                if showBuildBiDebugInput
                    log.info("[buildBi]   phase1: bar{0}({1}@{2,number,#.#}) 反向 → 起点确定", currF.barIndex, (currF.fType == FractalType.TOP ? "TOP" : "BOTTOM"), currF.price)
                break
        if j >= n
            if showBuildBiDebugInput
                log.info("[buildBi]   起点后无反向分型,无法成笔")
            break

        // 阶段2:确定终点——选评分最高的反向分型,遇同向分型即终止
        bestEndIdx = -1
        bestScore = -1e18
        k = j
        while k < n
            kF = rawFractals.get(k)
            if kF.fType == bestStart.fType
                if showBuildBiDebugInput
                    log.info("[buildBi]   phase2: candidate bar{0}({1}@{2,number,#.#}) same-type → 阶段2终止", kF.barIndex, (kF.fType == FractalType.TOP ? "TOP" : "BOTTOM"), kF.price)
                break
            kGap = kF.barIndex - bestStart.barIndex + 1
            kChgPct = math.abs(kF.price - bestStart.price) / bestStart.price * 100.0
            kValid = kGap >= minMergedBars and (minPriceChangePct <= 0 or kChgPct >= minPriceChangePct)
            if showBuildBiDebugInput
                log.info("[buildBi]   phase2: candidate bar{0}({1}@{2,number,#.#}) mergedGap={3} {4} priceChgPct={5,number,#.#}", kF.barIndex, (kF.fType == FractalType.TOP ? "TOP" : "BOTTOM"), kF.price, kGap, (kValid ? "valid" : "invalid"), kChgPct)
            if kValid
                if kChgPct > bestScore
                    bestScore := kChgPct
                    bestEndIdx := k
            k := k + 1

        if bestEndIdx < 0
            if showBuildBiDebugInput
                log.info("[buildBi]   round#{0} 无合格终点 → 孤立跳过", roundIdx)
            // 孤立跳过:新起点必须保持与上一笔终点同类型,保证笔方向严格交替。
            // 否则 B1→T1(UP) 后 T1→B2 不成笔,B2(底) 直接成为新起点会产生同向 UP 笔,
            // 违反缠论"笔方向严格交替"硬约束(T1→B2 间距不足被过滤的真实场景)。
            if strokes.size() > 0
                targetType = strokes.last().dir == StrokeDirection.UP ? FractalType.TOP : FractalType.BOTTOM
                startIdx := startIdx + 1
                while startIdx < n and rawFractals.get(startIdx).fType != targetType
                    startIdx := startIdx + 1
            else
                startIdx := startIdx + 1  // 首笔之前无类型约束,正常前进
            roundIdx := roundIdx + 1
            continue

        bestEnd = rawFractals.get(bestEndIdx)
        if showBuildBiDebugInput
            log.info("[buildBi]   bestEnd=bar{0} priceChgPct={1,number,#.#}", bestEnd.barIndex, bestScore)
        rawGap = bestEnd.originalIndex - bestStart.originalIndex + 1
        dir = bestStart.fType == FractalType.BOTTOM ? StrokeDirection.UP : StrokeDirection.DOWN
        chg = math.abs(bestEnd.price - bestStart.price)
        chgPct = chg / bestStart.price * 100.0
        strokes.push(Stroke.new(bestStart.originalIndex, bestEnd.originalIndex, bestStart.price, bestEnd.price, dir, rawGap, chg, chgPct))
        if showBuildBiDebugInput
            log.info("[buildBi]   stroke#{0}: bar{1}→bar{2} {3} rawGap={4} priceChgPct={5,number,#.#}", strokes.size() - 1, bestStart.barIndex, bestEnd.barIndex, (dir == StrokeDirection.UP ? "UP" : "DOWN"), rawGap, chgPct)
        startIdx := bestEndIdx
        roundIdx := roundIdx + 1

    strokes

// ============================================================
// 功能4: 笔区间提取 - getStrokeRange()
// ============================================================

// @function  提取一笔的价格区间 [strokeLow, strokeHigh]
// @param     s Stroke - 目标笔
// @returns   [float, float] - [低点, 高点]
//
// 向上笔(底→顶)和向下笔(顶→底)的高低点计算方向不同:
// - 向上笔:startPrice=底分型low, endPrice=顶分型high → [startPrice, endPrice]
// - 向下笔:startPrice=顶分型high, endPrice=底分型low → [endPrice, startPrice]
// 统一输出 [low, high] 避免后续代码处理两个方向的分支
getStrokeRange(s) =>
    if s.dir == StrokeDirection.UP
        [s.startPrice, s.endPrice]
    else
        [s.endPrice, s.startPrice]

// ============================================================
// 功能5: 中枢识别 - identifyZhongShu()
// ============================================================

// @function  从笔序列中识别价格中枢
// @param     strokes - M2 输出的笔数组
// @param     minStrokes - 构成中枢的最少笔数(禅师原文=3)
// @param     minWidthPct - 中枢最小宽度 ZG-ZD 的百分比阈值(0=不过滤)
// @returns   array<ZhongShu> - 识别到的中枢数组
//
// 算法(滑动窗口 + 向前延伸,两阶段):
//
// 阶段1 - 找中枢起点:
//   取连续三笔 s[i], s[i+1], s[i+2],计算三笔重叠区间:
//     ZG = min(s[i].high, s[i+1].high, s[i+2].high)
//     ZD = max(s[i].low,  s[i+1].low,  s[i+2].low)
//   如果 ZG > ZD 且宽度 ≥ minWidthPct → 中枢形成
//
// 阶段2 - 向前延伸:
//   尝试纳入 s[i+3]:
//     new_ZG = min(ZG, s[i+3].high)
//     new_ZD = max(ZD, s[i+3].low)
//   如果 new_ZG > new_ZD → 延伸成功,继续纳入下一笔
//   如果 new_ZG <= new_ZD → 延伸结束,保存当前中枢
//
// 时间复杂度:O(n),每笔最多被检查常数次
identifyZhongShu(strokes, minStrokes, minWidthPct) =>
    zhongShuList = array.new<ZhongShu>()
    n = strokes.size()
    if n < minStrokes
        zhongShuList

    i = 0
    while i <= n - minStrokes
        // 阶段1:检查三笔的重叠区间
        s0 = strokes.get(i)
        s1 = strokes.get(i + 1)
        s2 = strokes.get(i + 2)

        [s0Low, s0High] = getStrokeRange(s0)
        [s1Low, s1High] = getStrokeRange(s1)
        [s2Low, s2High] = getStrokeRange(s2)

        zg = math.min(s0High, s1High, s2High)
        zd = math.max(s0Low, s1Low, s2Low)
        gg = math.max(s0High, s1High, s2High)
        dd = math.min(s0Low, s1Low, s2Low)

        if zg > zd
            // 检查最小宽度
            currentPrice = (zg + zd) / 2.0
            widthPct = (zg - zd) / currentPrice * 100.0

            if minWidthPct > 0 and widthPct < minWidthPct
                i := i + 1
                continue

            // 记录初始核心边界(前三笔,延伸后不变)
            initialZG = zg
            initialZD = zd

            // 阶段2:向前延伸
            endIdx = i + 2
            j = i + 3

            while j < n
                sj = strokes.get(j)
                [sjLow, sjHigh] = getStrokeRange(sj)

                newZg = math.min(zg, sjHigh)
                newZd = math.max(zd, sjLow)

                if newZg > newZd
                    zg := newZg
                    zd := newZd
                    gg := math.max(gg, sjHigh)
                    dd := math.min(dd, sjLow)
                    endIdx := j
                    j := j + 1
                else
                    break

            // 保存中枢(strokeCount 达标才保存)
            startStroke = strokes.get(i)
            endStroke = strokes.get(endIdx)
            strokeCount = endIdx - i + 1
            if strokeCount >= minStrokes
                zhongShuList.push(ZhongShu.new(
                  i, endIdx,
                  startStroke.startOrigIndex, endStroke.endOrigIndex,
                  zg, zd, gg, dd,
                  strokeCount,
                  startStroke.dir,
                  initialZG, initialZD
                ))

            // 中枢之间至少隔一笔,不共享边界笔。
            // 否则相邻中枢共享 stroke[endIdx] 会导致趋势背驰的进入段/离开段
            // 全部落在中枢内部变成盘整笔,永远无法满足"价格更极端+力度衰竭"条件。
            i := endIdx + 1
        else
            i := i + 1

    zhongShuList

// ============================================================
// 功能6: MACD 面积计算 - calcMacdArea()
// ============================================================

// @function  计算一笔的 MACD 柱面积
// @param     s Stroke - 目标笔
// @param     histValues array<float> - 预存的 MACD 柱值数组
// @param     useAbsolute - true = 绝对值面积, false = 代数面积(正+负)
// @returns   float - MACD 面积
//
// 面积计算支持两种方式:
// - 代数面积(useAbsolute=false):sum(macdHist),正柱加正值,负柱减面积
//   向上笔通常为正面积,向下笔通常为负面积 → 调用方取 math.abs() 比较
// - 绝对值面积(useAbsolute=true):sum(|macdHist|),忽略方向只看力度
calcMacdArea(s, histValues, useAbsolute) =>
    area = 0.0
    startIdx = s.startOrigIndex
    endIdx = s.endOrigIndex
    if startIdx >= 0 and endIdx < histValues.size()
        for i = startIdx to endIdx
            val = histValues.get(i)
            area += useAbsolute ? math.abs(val) : val
    area

// ============================================================
// 功能7: 趋势背驰检测 - detectTrendBeiChi()
// ============================================================

// @function  检测趋势背驰(跨中枢的同向趋势笔比较)
// @param     strokes - 笔数组
// @param     zhongShuList - 中枢数组
// @param     histValues - MACD 柱值数组
// @param     areaRatioThreshold - 面积比阈值
// @returns   array<BeiChiSignal> - 趋势背驰信号数组
//
// 算法:三步走
//
// Step 1: 标记每笔是否在中枢内部
//   - 遍历所有中枢,将 [startStrokeIdx, endStrokeIdx] 范围内的笔标记为"中枢内"
//
// Step 2: 收集趋势笔
//   - 不在任何中枢内的笔 = 趋势笔
//
// Step 3: 跨中枢比较同向趋势笔对
//   - 两个同向趋势笔 a 和 b,要求它们之间至少隔了一个完整的中枢
//   - 顶背驰(UP):b.endPrice > a.endPrice AND areaB < areaA * threshold
//   - 底背驰(DOWN):b.endPrice < a.endPrice AND areaB < areaA * threshold
//
// 关键区别:a 和 b 都是真正的趋势笔(在中枢外),不是盘整笔。
// 这解决了旧实现中 "strokes[start-1] 永远是前一个中枢的盘整笔" 的根因。
detectTrendBeiChi(strokes, zhongShuList, histValues, areaRatioThreshold) =>
    signals = array.new<BeiChiSignal>()
    n = strokes.size()
    if n < 3 or zhongShuList.size() == 0
        signals

    // Step 1: 标记中枢内笔(0=趋势笔, 1=中枢内)
    inZS = array.new<int>()
    for k = 0 to n - 1
        inZS.push(0)

    for zs in zhongShuList
        for k = zs.startStrokeIdx to zs.endStrokeIdx
            inZS.set(k, 1)

    // Step 2: 收集趋势笔索引
    trendIndices = array.new<int>()
    for k = 0 to n - 1
        if inZS.get(k) == 0
            trendIndices.push(k)

    // Step 3: 预计算"两笔间是否有中枢"的 O(1) 查询表
    // firstZSAfter[i] = zhongShuList 中第一个 startStrokeIdx > i 的中枢索引(-1 表示无)
    firstZSAfter = array.new<int>()
    for k = 0 to n - 1
        firstZSAfter.push(-1)

    zsIdx = 0
    zsN = zhongShuList.size()
    for k = 0 to n - 1
        while zsIdx < zsN and zhongShuList.get(zsIdx).startStrokeIdx <= k
            zsIdx := zsIdx + 1
        if zsIdx < zsN
            firstZSAfter.set(k, zsIdx)

    // Step 4: 跨中枢比较同向趋势笔对
    // 用预计算表替代原来的内层 O(z) 中枢扫描,总复杂度 O(m²) 每对 O(1)
    m = trendIndices.size()
    if m < 2
        signals

    for i = 0 to m - 2
        aIdx = trendIndices.get(i)
        a = strokes.get(aIdx)

        for j = i + 1 to m - 1
            bIdx = trendIndices.get(j)
            b = strokes.get(bIdx)

            // 必须同向
            if a.dir != b.dir
                continue

            // O(1) 中枢检查:a 之后第一个中枢的 endStrokeIdx 是否小于 bIdx
            zsAfterA = firstZSAfter.get(aIdx)
            hasZS = zsAfterA >= 0 and zhongShuList.get(zsAfterA).endStrokeIdx < bIdx
            if not hasZS
                continue

            areaA = math.abs(calcMacdArea(a, histValues, macdAbsoluteInput))
            areaB = math.abs(calcMacdArea(b, histValues, macdAbsoluteInput))

            if areaA <= 0 or areaB <= 0
                continue

            areaRatio = areaB / areaA

            if b.dir == StrokeDirection.UP
                if b.endPrice > a.endPrice and areaRatio < areaRatioThreshold
                    signals.push(BeiChiSignal.new(
                      bIdx, aIdx, b.endOrigIndex,
                      areaB, areaA, areaRatio, BeiChiType.TOP
                    ))
            else
                if b.endPrice < a.endPrice and areaRatio < areaRatioThreshold
                    signals.push(BeiChiSignal.new(
                      bIdx, aIdx, b.endOrigIndex,
                      areaB, areaA, areaRatio, BeiChiType.BOTTOM
                    ))

    signals

// ============================================================
// 功能8: 盘整背驰检测 - detectConsolidationBeiChi()
// ============================================================

// @function  检测盘整背驰(中枢内部同向笔力度比较)
// @param     strokes - 笔数组
// @param     zhongShuList - 中枢数组
// @param     histValues - MACD 柱值数组
// @param     areaRatioThreshold - 面积比阈值
// @returns   array<BeiChiSignal> - 盘整背驰信号数组
//
// 算法:在每个中枢内部,比较同向笔的 MACD 面积变化。
// 笔序列天然交替(上→下→上→下),所以 strokes[i] 和 strokes[i+2] 必同向。
//
//   顶盘整背驰:b.high > a.high 但 areaB < areaA * threshold(价格抬升但力竭)
//   底盘整背驰:b.low  < a.low  但 areaB < areaA * threshold(价格下移但力竭)
detectConsolidationBeiChi(strokes, zhongShuList, histValues, areaRatioThreshold) =>
    signals = array.new<BeiChiSignal>()

    for zs in zhongShuList
        endLimit = zs.endStrokeIdx - 2
        for i = zs.startStrokeIdx to endLimit
            a = strokes.get(i)
            b = strokes.get(i + 2)  // 隔一笔,必同向

            if a.dir != b.dir
                continue

            areaA = math.abs(calcMacdArea(a, histValues, macdAbsoluteInput))
            areaB = math.abs(calcMacdArea(b, histValues, macdAbsoluteInput))

            if areaA <= 0 or areaB <= 0
                continue

            areaRatio = areaB / areaA

            if b.dir == StrokeDirection.UP
                if b.endPrice > a.endPrice and areaRatio < areaRatioThreshold
                    signals.push(BeiChiSignal.new(
                      i + 2, i, b.endOrigIndex,
                      areaB, areaA, areaRatio, BeiChiType.TOP
                    ))
            else
                if b.endPrice < a.endPrice and areaRatio < areaRatioThreshold
                    signals.push(BeiChiSignal.new(
                      i + 2, i, b.endOrigIndex,
                      areaB, areaA, areaRatio, BeiChiType.BOTTOM
                    ))

    signals

// ============================================================
// 功能9: 第一类买卖点 B1/S1 - detectFirstSignals()
// ============================================================

// @function  从趋势背驰信号生成第一类买卖点
// @param     trendBCSignals - M4 趋势背驰信号数组
// @param     strokes - 笔数组
// @returns   array<TradingSignal> - B1/S1 信号
//
// 一一对应关系:
// - 趋势底背驰 (BeiChiType.BOTTOM) → B1: 下跌趋势衰竭,买入
// - 趋势顶背驰 (BeiChiType.TOP) → S1: 上涨趋势衰竭,卖出
//
// B1 价格 = 离开段向下笔的终点(底分型 low)
// S1 价格 = 离开段向上笔的终点(顶分型 high)
detectFirstSignals(trendBCSignals, strokes) =>
    signals = array.new<TradingSignal>()

    for bc in trendBCSignals
        bStroke = strokes.get(bc.strokeIndex)

        if bc.bcType == BeiChiType.BOTTOM
            signals.push(TradingSignal.new(
              bc.barIndex, bStroke.endPrice, SignalType.B1,
              bc.strokeIndex,
              str.format("B1\n{0,number,#.##}", bStroke.endPrice)
            ))
        else if bc.bcType == BeiChiType.TOP
            signals.push(TradingSignal.new(
              bc.barIndex, bStroke.endPrice, SignalType.S1,
              bc.strokeIndex,
              str.format("S1\n{0,number,#.##}", bStroke.endPrice)
            ))

    signals

// ============================================================
// 功能10: 第三类买卖点 B3/S3 - detectThirdSignals()
// ============================================================

// @function  检测第三类买卖点(中枢边界支撑/压力突破后的回踩确认)
// @param     strokes - 笔数组
// @param     zhongShuList - 中枢数组
// @returns   array<TradingSignal> - B3/S3 信号
//
// B3(中枢上方三买):"中枢上方回调不破 ZG 就是买"
//   1. 中枢后第一笔向上离开(突破 ZG)
//   2. 第二笔向下回调
//   3. 回调最低点 > ZG(不破中枢上沿)→ B3 触发
//
// S3(中枢下方三卖,B3 的镜像):"中枢下方反弹不破 ZD 就是卖"
//   1. 中枢后第一笔向下离开(跌破 ZD)
//   2. 第二笔向上反弹
//   3. 反弹最高点 < ZD(不破中枢下沿)→ S3 触发
detectThirdSignals(strokes, zhongShuList) =>
    signals = array.new<TradingSignal>()
    n = strokes.size()

    for zsIdx = 0 to zhongShuList.size() - 1
        zs = zhongShuList.get(zsIdx)

        // 中枢后至少需要 2 笔:离开段 + 回调段
        bIdx = zs.endStrokeIdx + 1
        cIdx = bIdx + 1

        if cIdx >= n
            continue

        leaveStroke    = strokes.get(bIdx)  // 离开中枢的笔
        pullbackStroke = strokes.get(cIdx)  // 回调/反弹笔

        // B3: 向上离开 → 向下回调 → 不破 ZG
        if leaveStroke.dir == StrokeDirection.UP and pullbackStroke.dir == StrokeDirection.DOWN
            [pbLow, pbHigh] = getStrokeRange(pullbackStroke)
            if pbLow > zs.initialZG
                signals.push(TradingSignal.new(
                  pullbackStroke.endOrigIndex,
                  pullbackStroke.endPrice,
                  SignalType.B3, zsIdx,
                  str.format("B3\n>{0,number,#.##}", zs.initialZG)
                ))

        // S3: 向下离开 → 向上反弹 → 不破 ZD
        if leaveStroke.dir == StrokeDirection.DOWN and pullbackStroke.dir == StrokeDirection.UP
            [pbLow, pbHigh] = getStrokeRange(pullbackStroke)
            if pbHigh < zs.initialZD
                signals.push(TradingSignal.new(
                  pullbackStroke.endOrigIndex,
                  pullbackStroke.endPrice,
                  SignalType.S3, zsIdx,
                  str.format("S3\n<{0,number,#.##}", zs.initialZD)
                ))

    signals

// ============================================================
// 功能11: 第二类买卖点 B2/S2 - detectSecondSignals()
// ============================================================

// @function  检测第二类买卖点(B1/S1 之后的二次回踩确认)
// @param     firstSignals - M5 第一类买卖点 B1/S1 数组
// @param     strokes - 笔数组
// @returns   array<TradingSignal> - B2/S2 信号
//
// B2(B1 后的二次买入):"一买反弹后回踩不创新低"
//   1. 找到 B1 信号
//   2. B1 之后的第一笔向上(反弹)+ 第二笔向下(回踩)
//   3. 回踩终点 > B1 价格(不创新低)→ B2 触发
//
// S2(S1 后的二次卖出,B2 的镜像):"一卖回落后反弹不创新高"
//   1. 找到 S1 信号
//   2. S1 之后的第一笔向下(回落)+ 第二笔向上(反弹)
//   3. 反弹终点 < S1 价格(不创新高)→ S2 触发
//
// 每个 B1/S1 只产生第一个 B2/S2(break 后不再继续找)
detectSecondSignals(firstSignals, strokes) =>
    signals = array.new<TradingSignal>()

    for fs in firstSignals
        // B2: B1 之后的二次买入
        if fs.sType == SignalType.B1
            startJ = fs.refIndex + 1
            endJ = strokes.size() - 2
            if startJ <= endJ
                for j = startJ to endJ
                    s1 = strokes.get(j)      // 反弹,应为 UP
                    s2 = strokes.get(j + 1)  // 回踩,应为 DOWN

                    if s1.dir != StrokeDirection.UP or s2.dir != StrokeDirection.DOWN
                        continue

                    if s2.endPrice > fs.price
                        signals.push(TradingSignal.new(
                          s2.endOrigIndex,
                          s2.endPrice,
                          SignalType.B2, fs.refIndex,
                          str.format("B2\n>{0,number,#.##}", fs.price)
                        ))
                        break

        // S2: S1 之后的二次卖出
        if fs.sType == SignalType.S1
            startJ = fs.refIndex + 1
            endJ = strokes.size() - 2
            if startJ <= endJ
                for j = startJ to endJ
                    s1 = strokes.get(j)      // 回落,应为 DOWN
                    s2 = strokes.get(j + 1)  // 反弹,应为 UP

                    if s1.dir != StrokeDirection.DOWN or s2.dir != StrokeDirection.UP
                        continue

                    if s2.endPrice < fs.price
                        signals.push(TradingSignal.new(
                          s2.endOrigIndex,
                          s2.endPrice,
                          SignalType.S2, fs.refIndex,
                          str.format("S2\n<{0,number,#.##}", fs.price)
                        ))
                        break

    signals

// ============================================================
// 功能12: 信号间距过滤 - filterSignalSpacing()
// ============================================================

// @function  过滤同类型信号,保留间距 ≥ minBarsBetween 的信号
// @param     signals - 所有信号数组(按 barIndex 升序排列)
// @param     minBarsBetween - 同类型信号最少间隔 K 线数
// @returns   array<TradingSignal> - 过滤后的信号
//
// 用 6 个 var int 跟踪每种信号的上一 barIndex,替代 Pine Script 不支持的 Map
filterSignalSpacing(signals, minBarsBetween) =>
    // 用每次新建的 array<int> 替代 var int,避免跨调用污染
    // var int 在函数内跨 bar 持久化,第二次调用时保留上次的 lastBar 值
    lastBars = array.new<int>(6, -5000)  // [B1,B2,B3,S1,S2,S3]

    filtered = array.new<TradingSignal>()

    for s in signals
        typeIdx = switch s.sType
            SignalType.B1 => 0
            SignalType.B2 => 1
            SignalType.B3 => 2
            SignalType.S1 => 3
            SignalType.S2 => 4
            SignalType.S3 => 5

        if s.barIndex - lastBars.get(typeIdx) >= minBarsBetween
            filtered.push(s)
            lastBars.set(typeIdx, s.barIndex)

    filtered

// ============================================================
// 功能12b: 信号触发状态查询 - isSignalTriggered()
// ============================================================

// @function  检查指定类型的信号是否已在给定笔上触发
// @param     sType SignalType - 要查询的信号类型
// @param     strokeIdx int - 目标笔在 strokes 中的索引
// @param     signals array<TradingSignal> - 已过滤的信号数组
// @returns   bool - true = 该笔上已有对应信号触发
isSignalTriggered(sType, strokeIdx, signals) =>
    result = false
    for sig in signals
        if sig.sType == sType and sig.refIndex == strokeIdx
            result := true
            break
    result

// ============================================================
// 功能12c: 止损计算 - getStopLossForSignal()
// ============================================================

// @function  计算信号对应的参考止损价格
// @param     sType SignalType - 信号类型
// @param     entryPrice float - 进场价格(信号触发价)
// @param     refPrice float - 参考价格(B2=对应 B1 价格, S2=对应 S1 价格, na=不适用)
// @param     zd float - 中枢下沿 ZD(B3 止损用, na=不适用)
// @param     zg float - 中枢上沿 ZG(S3 止损用, na=不适用)
// @param     atrValue float - 当前 ATR 值(用于 B1/S1 动态止损距离)
// @returns   float - 参考止损价格(na = 无法计算)
//
// 止损规则:
// - B1: entryPrice - max(ATR×倍数, 2%)  ← 动态波动率 + 百分比兜底
// - B2: refPrice(B1 的低点)           ← 不破 B1 前低
// - B3: zd(中枢下沿)                   ← 不破中枢下沿
// - S1: entryPrice + max(ATR×倍数, 2%)  ← B1 的镜像
// - S2: refPrice(S1 的高点)           ← 不破 S1 前高
// - S3: zg(中枢上沿)                   ← 不破中枢上沿
getStopLossForSignal(sType, entryPrice, refPrice, zd, zg, atrValue) =>
    atrDist  = atrValue * stopLossAtrMultiplierInput
    pctDist  = entryPrice * 0.02
    stopDist = math.max(atrDist, pctDist)

    switch sType
        SignalType.B1 => entryPrice - stopDist
        SignalType.B2 => na(refPrice) ? entryPrice - pctDist : refPrice
        SignalType.B3 => na(zd) ? entryPrice - pctDist : zd
        SignalType.S1 => entryPrice + stopDist
        SignalType.S2 => na(refPrice) ? entryPrice + pctDist : refPrice
        SignalType.S3 => na(zg) ? entryPrice + pctDist : zg

// ============================================================
// 功能12d: 趋势状态检测 - getTrendState()
// ============================================================

// @function  基于最近 N 笔的区间比较判断当前趋势状态
// @param     strokes array<Stroke> - 笔数组
// @param     lookback int - 比较窗口大小(默认 5)
// @returns   string - "上升" / "下降" / "盘整"
//
// 算法:取最近 lookback 笔的最高高点和最低低点,与之前 lookback 笔比较。
// - 近期高点 > 前期高点 AND 近期低点 > 前期低点 → "上升"
// - 近期高点 < 前期高点 AND 近期低点 < 前期低点 → "下降"
// - 其他 → "盘整"
getTrendState(strokes, lookback) =>
    n = strokes.size()
    if n < lookback * 2
        "盘整"

    count = math.min(lookback, math.floor(n / 2))
    recentStart = n - count

    // 近期区间
    recentHigh = -1e18
    recentLow  =  1e18
    for i = recentStart to n - 1
        [sLow, sHigh] = getStrokeRange(strokes.get(i))
        recentHigh := math.max(recentHigh, sHigh)
        recentLow  := math.min(recentLow, sLow)

    // 前期区间
    priorStart = math.max(0, recentStart - count)
    priorEnd   = recentStart - 1
    priorHigh  = -1e18
    priorLow   =  1e18
    for i = priorStart to priorEnd
        [sLow, sHigh] = getStrokeRange(strokes.get(i))
        priorHigh := math.max(priorHigh, sHigh)
        priorLow  := math.min(priorLow, sLow)

    if recentHigh > priorHigh and recentLow > priorLow
        "上升"
    else if recentHigh < priorHigh and recentLow < priorLow
        "下降"
    else
        "盘整"

// ============================================================
// 功能12e: 信号质量评分 - scoreSignal()
// ============================================================

// @function  计算信号的综合质量评分(0-100)
// @param     sType SignalType - 信号类型
// @param     refIndex int - 关联索引(B1/S1=笔索引, B3/S3=中枢索引, B2/S2=B1笔索引)
// @param     barIndex int - 信号 bar 索引(用于定位相关中枢)
// @param     trendBCSignals array<BeiChiSignal> - 趋势背驰信号(查 areaRatio)
// @param     zhongShuList array<ZhongShu> - 中枢列表(查成熟度)
// @param     trendState string - 预计算的趋势状态
// @returns   int - 质量评分 0-100
//
// 评分因子(加权):
//   - 背驰强度 45%:areaRatio < 0.3→100, < 0.5→80, < 0.7→50, < 0.9→20, ≥ 0.9→0
//     B2/B3/S2/S3 无背驰数据 → 默认 50(中性)
//   - 中枢成熟度 25%:中枢笔数 7+→100, 5-6→60, 4→40, 3→30
//   - 趋势一致性 30%:顺势→100, 盘整→50, 逆势→20
scoreSignal(sType, refIndex, barIndex, trendBCSignals, zhongShuList, trendState) =>
    // 因子1: 背驰强度(仅 B1/S1 有 BeiChiSignal)
    bcScore = 50
    if sType == SignalType.B1 or sType == SignalType.S1
        targetType = sType == SignalType.B1 ? BeiChiType.BOTTOM : BeiChiType.TOP
        for bc in trendBCSignals
            if bc.bcType == targetType and bc.strokeIndex == refIndex
                ar = bc.areaRatio
                if ar < 0.3
                    bcScore := 100
                else if ar < 0.5
                    bcScore := 80
                else if ar < 0.7
                    bcScore := 50
                else if ar < 0.9
                    bcScore := 20
                else
                    bcScore := 0
                break

    // 因子2: 中枢成熟度(信号 barIndex 之前最后一个中枢的笔数)
    zsStrokeCount = 3
    for zs in zhongShuList
        if zs.endOrigIndex <= barIndex
            zsStrokeCount := zs.strokeCount
    zsScore = zsStrokeCount >= 7 ? 100 :
              zsStrokeCount >= 5 ? 60  :
              zsStrokeCount >= 4 ? 40  : 30

    // 因子3: 趋势一致性
    isBuy = sType == SignalType.B1 or sType == SignalType.B2 or sType == SignalType.B3
    trendScore = 50
    if trendState == "上升" and isBuy
        trendScore := 100
    else if trendState == "下降" and not isBuy
        trendScore := 100
    else if trendState == "上升" and not isBuy
        trendScore := 20
    else if trendState == "下降" and isBuy
        trendScore := 20

    math.round(bcScore * 0.45 + zsScore * 0.25 + trendScore * 0.30)

// ============================================================
// 功能13: 实时预判计算 - computePredictions()
// ============================================================

// @function  基于最新 M1-M5 状态计算各类信号的触发价格阈值
// @param     strokes, zhongShuList, trendBCSignals, allFilteredSignals - M2-M5 输出
// @returns   array<Prediction> - 预判条目数组(最多 6 条)
//
// 预判 ≠ 预测。预判是确定性的阈值计算:"如果价格到 X,可能触发信号 Y"。
// 只在 barstate.isrealtime 时调用,不影响任何历史计算。
computePredictions(strokes, zhongShuList, trendBCSignals, allFilteredSignals) =>
    predictions = array.new<Prediction>()

    // --- B1: 趋势底背驰买点预判 ---
    // 结构条件:最后一个中枢前有向下的进入段 + 中枢后有向下的离开段
    // 触发阈值:离开段跌破进入段终点 → 配合 MACD 背驰确认 → B1
    // 算法:
    //   1. 找到最后一个中枢 lastZS
    //   2. 进入段参考笔 = strokes[lastZS.startStrokeIdx - 1](中枢之前的那笔趋势笔,方向必须 DOWN)
    //   3. 离开段 = strokes.last()(方向必须 DOWN,且在中枢之后)
    //   4. 触发价 = refStroke.endPrice(参考前低)
    //   5. 状态:已触发(B1 已生成)/ 观察背驰(已跌破前低)/ 等待中(价格在前低上方)
    b1PredictionGenerated = false

    if zhongShuList.size() > 0
        lastZS = zhongShuList.get(zhongShuList.size() - 1)

        // Step 1: 定位进入段参考笔(中枢开始之前的那笔趋势笔,必须向下)
        if lastZS.startStrokeIdx > 0
            refStrokeIdx = lastZS.startStrokeIdx - 1
            refStroke = strokes.get(refStrokeIdx)

            if refStroke.dir == StrokeDirection.DOWN
                // Step 2: 定位离开段(中枢之后的向下笔)
                lastStrokeIdx = strokes.size() - 1

                if lastStrokeIdx > lastZS.endStrokeIdx
                    lastStroke = strokes.get(lastStrokeIdx)

                    if lastStroke.dir == StrokeDirection.DOWN
                        triggerPrice = refStroke.endPrice

                        // Step 3: 判断状态
                        statusText = ""
                        if isSignalTriggered(SignalType.B1, lastStrokeIdx, allFilteredSignals)
                            statusText := "✅ 已触发"
                        else if close <= triggerPrice
                            statusText := "⏳ 观察背驰"
                        else
                            statusText := "⏳ 等待中"

                        predictions.push(Prediction.new(
                          "B1", triggerPrice,
                          str.format("跌破前低{0,number,#.##} + MACD背驰 → B1", triggerPrice),
                          "DOWN",
                          statusText,
                          getStopLossForSignal(SignalType.B1, triggerPrice, float(na), float(na), float(na), atrCurrent)
                        ))
                        b1PredictionGenerated := true

        // Step 4: 离开段实时感知——最后一笔仍在中枢内,但实时价格已向下突破 ZD
        if not b1PredictionGenerated
            lastStrokeIdx = strokes.size() - 1
            if lastStrokeIdx <= lastZS.endStrokeIdx and lastZS.startStrokeIdx > 0
                refStroke = strokes.get(lastZS.startStrokeIdx - 1)
                if refStroke.dir == StrokeDirection.DOWN and low < lastZS.initialZD
                    triggerPrice = refStroke.endPrice
                    predictions.push(Prediction.new(
                      "B1", triggerPrice,
                      str.format("向下离开中枢 + 跌破{0,number,#.##} + MACD背驰 → B1", triggerPrice),
                      "DOWN",
                      "⏳ 形成中",
                      getStopLossForSignal(SignalType.B1, triggerPrice, float(na), float(na), float(na), atrCurrent)
                    ))
                    b1PredictionGenerated := true

    // --- S1: 趋势顶背驰卖点预判(B1 的镜像)---
    // 结构条件:最后一个中枢前有向上的进入段 + 中枢后有向上的离开段
    // 触发阈值:离开段突破进入段终点 → 配合 MACD 背驰确认 → S1
    if zhongShuList.size() > 0
        lastZS = zhongShuList.get(zhongShuList.size() - 1)

        if lastZS.startStrokeIdx > 0
            refStrokeIdx = lastZS.startStrokeIdx - 1
            refStroke = strokes.get(refStrokeIdx)

            if refStroke.dir == StrokeDirection.UP
                lastStrokeIdx = strokes.size() - 1

                if lastStrokeIdx > lastZS.endStrokeIdx
                    lastStroke = strokes.get(lastStrokeIdx)

                    if lastStroke.dir == StrokeDirection.UP
                        triggerPrice = refStroke.endPrice

                        statusText = ""
                        if isSignalTriggered(SignalType.S1, lastStrokeIdx, allFilteredSignals)
                            statusText := "✅ 已触发"
                        else if close >= triggerPrice
                            statusText := "⏳ 观察背驰"
                        else
                            statusText := "⏳ 等待中"

                        predictions.push(Prediction.new(
                          "S1", triggerPrice,
                          str.format("突破前高{0,number,#.##} + MACD背驰 → S1", triggerPrice),
                          "UP",
                          statusText,
                          getStopLossForSignal(SignalType.S1, triggerPrice, float(na), float(na), float(na), atrCurrent)
                        ))

                // realtime 感知:价格已向上突破 ZG,离开段正在形成
                else if lastStrokeIdx <= lastZS.endStrokeIdx
                    if high > lastZS.initialZG and refStroke.dir == StrokeDirection.UP
                        predictions.push(Prediction.new(
                          "S1", refStroke.endPrice,
                          str.format("向上离开中枢 + 突破{0,number,#.##} + MACD背驰 → S1", refStroke.endPrice),
                          "UP",
                          "⏳ 形成中",
                          getStopLossForSignal(SignalType.S1, refStroke.endPrice, float(na), float(na), float(na), atrCurrent)
                        ))

    // --- B3: 中枢上方三买预判 ---
    // 前件条件:中枢后第一笔向上离开 + 当前价仍在 ZG 上方
    // --- S3: 中枢下方三卖预判 ---
    // 前件条件:中枢后第一笔向下离开 + 当前价仍在 ZD 下方
    if zhongShuList.size() > 0
        zsIdx = zhongShuList.size() - 1
        lastZS = zhongShuList.get(zsIdx)
        leaveIdx = lastZS.endStrokeIdx + 1

        if leaveIdx < strokes.size()
            leaveStroke = strokes.get(leaveIdx)

            // B3: 向上离开 + 价格仍在 ZG 上方
            if leaveStroke.dir == StrokeDirection.UP and close > lastZS.initialZG
                statusText = ""
                if isSignalTriggered(SignalType.B3, zsIdx, allFilteredSignals)
                    statusText := "✅ 已触发"
                else
                    statusText := "⏳ 等待中"

                predictions.push(Prediction.new(
                  "B3", lastZS.initialZG,
                  str.format("回调不破ZG={0,number,#.##} → B3", lastZS.initialZG),
                  "DOWN",
                  statusText,
                  getStopLossForSignal(SignalType.B3, lastZS.initialZG, float(na), lastZS.initialZD, lastZS.initialZG, atrCurrent)
                ))

            // S3: 向下离开 + 价格仍在 ZD 下方
            if leaveStroke.dir == StrokeDirection.DOWN and close < lastZS.initialZD
                statusText = ""
                if isSignalTriggered(SignalType.S3, zsIdx, allFilteredSignals)
                    statusText := "✅ 已触发"
                else
                    statusText := "⏳ 等待中"

                predictions.push(Prediction.new(
                  "S3", lastZS.initialZD,
                  str.format("反弹不破ZD={0,number,#.##} → S3", lastZS.initialZD),
                  "UP",
                  statusText,
                  getStopLossForSignal(SignalType.S3, lastZS.initialZD, float(na), lastZS.initialZD, lastZS.initialZG, atrCurrent)
                ))

    // --- B2: B1 后的二次买入预判 ---
    // 前件条件:B1 后有向上反弹笔 + 当前价仍在 B1 价格上方
    i = allFilteredSignals.size() - 1
    while i >= 0
        sig = allFilteredSignals.get(i)
        if sig.sType == SignalType.B1
            b1RefIdx = sig.refIndex
            reboundIdx = b1RefIdx + 1
            if reboundIdx < strokes.size()
                reboundStroke = strokes.get(reboundIdx)
                if reboundStroke.dir == StrokeDirection.UP
                    if close > sig.price
                        statusText = ""
                        if isSignalTriggered(SignalType.B2, b1RefIdx, allFilteredSignals)
                            statusText := "✅ 已触发"
                        else
                            statusText := "⏳ 等待中"

                        predictions.push(Prediction.new(
                          "B2", sig.price,
                          str.format("回踩不破B1={0,number,#.##} → B2", sig.price),
                          "DOWN",
                          statusText,
                          getStopLossForSignal(SignalType.B2, sig.price, sig.price, float(na), float(na), atrCurrent)
                        ))
            break
        i := i - 1

    // --- S2: S1 后的二次卖出预判(B2 的镜像)---
    // 前件条件:S1 后有向下回落笔 + 当前价仍在 S1 价格下方
    i := allFilteredSignals.size() - 1
    while i >= 0
        sig = allFilteredSignals.get(i)
        if sig.sType == SignalType.S1
            s1RefIdx = sig.refIndex
            reboundIdx = s1RefIdx + 1
            if reboundIdx < strokes.size()
                reboundStroke = strokes.get(reboundIdx)
                if reboundStroke.dir == StrokeDirection.DOWN
                    if close < sig.price
                        statusText = ""
                        if isSignalTriggered(SignalType.S2, s1RefIdx, allFilteredSignals)
                            statusText := "✅ 已触发"
                        else
                            statusText := "⏳ 等待中"

                        predictions.push(Prediction.new(
                          "S2", sig.price,
                          str.format("反弹不破S1={0,number,#.##} → S2", sig.price),
                          "UP",
                          statusText,
                          getStopLossForSignal(SignalType.S2, sig.price, sig.price, float(na), float(na), atrCurrent)
                        ))
            break
        i := i - 1

    predictions

// ============================================================
// MAIN LOGIC
// ============================================================

// --- 持久化数据 ---
// mergedBars: 包含处理后的K线序列,增量构建
// mergedHighMap / mergedLowMap / mergedCountMap: 原始 bar_index → 合并后属性的映射表
// fractals: 原始分型序列(未经交替过滤,仅用于 plotshape 三角标记和文字标签)
var array<MergedBar> mergedBars      = array.new<MergedBar>()
var array<float>    mergedHighMap    = array.new<float>()
var array<float>    mergedLowMap     = array.new<float>()
var array<int>      mergedCountMap   = array.new<int>()
var array<Fractal>  fractals         = array.new<Fractal>()
var array<Stroke>   strokes          = array.new<Stroke>()
var array<ZhongShu> zhongShuList     = array.new<ZhongShu>()
var array<int>      barTimes         = array.new<int>()  // 每根原始 bar 的 time 时间戳
var array<float>    macdHistValues   = array.new<float>()  // 每根原始 bar 的 MACD 柱值
var array<BeiChiSignal> trendBeiChiSignals = array.new<BeiChiSignal>()  // 趋势背驰信号
var array<BeiChiSignal> consolidBeiChiSignals = array.new<BeiChiSignal>()  // 盘整背驰信号
var array<TradingSignal> firstSignals        = array.new<TradingSignal>()  // 第一类买卖点 B1/S1
var array<TradingSignal> thirdSignals        = array.new<TradingSignal>()  // 第三类买卖点 B3/S3
var array<TradingSignal> secondSignals       = array.new<TradingSignal>()  // 第二类买卖点 B2/S2
var array<TradingSignal> allFilteredSignals  = array.new<TradingSignal>()  // 过滤后的所有买卖点
var array<label>     fractalLabels      = array.new<label>()       // 分型文字标签(跟踪生命周期,防止泄漏)
var array<label>     debugMergeLabels   = array.new<label>()       // 调试模式"X合"标签(跟踪生命周期,防止泄漏)
var array<Prediction> cachedPredictions  = array.new<Prediction>() // 预判结果缓存(面板和告警复用)
var bool             predictionsCached  = false                   // 当前 bar 是否已有缓存

// --- 增量包含处理(每根已确认K线执行,O(1)摊销)---
if barstate.isconfirmed
    rawBar = MergedBar.new(
      bar_index, 1, high, low, open, close, TrendDirection.UP
    )
    mergedBars.push(rawBar)
    barTimes.push(time)
    [_, _, macdHist] = ta.macd(close, macdFastInput, macdSlowInput, macdSignalInput)
    macdHistValues.push(macdHist)

    if mergedBars.size() >= 2
        i = mergedBars.size() - 1
        while i >= 1
            prev = mergedBars.get(i - 1)
            curr = mergedBars.get(i)

            if hasContainment(prev, curr)
                dir = TrendDirection.UP
                if i >= 2
                    prevPrev = mergedBars.get(i - 2)
                    dir := getTrendDirection(prevPrev, prev)

                merged = mergeTwoBars(prev, curr, dir)
                mergedBars.set(i - 1, merged)
                mergedBars.remove(i)
                i := i - 1
            else
                break

// --- 全量映射构建 + 分型计算 ---
// 触发时机:
//   1. 历史加载完成时 (islastconfirmedhistory) —— 一次性构建
//   2. 实时 bar 收盘确认时 (isconfirmed and not ishistory) —— 每次收盘重建
// 不在历史加载期间 (ishistory) 每根 bar 都重建,避免 O(n²)
// 每次重建 O(n),n=合并后K线数(≤5000),实时每根 bar 收盘执行一次,完全可接受
bool needFullRebuild = barstate.islastconfirmedhistory or (barstate.isconfirmed and not barstate.ishistory)

if needFullRebuild
    // 构建映射表:每个原始 bar_index → {合并后high, 合并后low, 合并K线包含的原始K线数}
    mergedHighMap.clear()
    mergedLowMap.clear()
    mergedCountMap.clear()

    for mb in mergedBars
        for j = 0 to mb.mergedCount - 1
            mergedHighMap.push(mb.high)
            mergedLowMap.push(mb.low)
            mergedCountMap.push(mb.mergedCount)

    // 识别所有分型 → 不再预过滤(同向分型处理已内化到 buildBi)
    rawFractals = identifyFractals(mergedBars)

    // fractals 数组仅用于 plotshape 分型三角标记,使用原始分型即可
    fractals.clear()
    for f in rawFractals
        fractals.push(f)

    // 从分型序列构建笔序列(消费 M1 输出,原始分型直接进入 buildBi)
    newStrokes = buildBi(rawFractals, minBarsForBiInput, minBiPriceChangeInput)
    strokes.clear()
    for s in newStrokes
        strokes.push(s)

    // 从笔序列识别中枢(消费 M2 输出)
    newZS = identifyZhongShu(strokes, minStrokesForZSInput, minZSWidthInput)
    zhongShuList.clear()
    for zs in newZS
        zhongShuList.push(zs)

    // 趋势背驰检测(消费 M2+M3 输出)
    newTrendBC = detectTrendBeiChi(strokes, zhongShuList, macdHistValues, areaRatioThresholdInput)
    trendBeiChiSignals.clear()
    for bc in newTrendBC
        trendBeiChiSignals.push(bc)

    // 盘整背驰检测
    newConsolidBC = detectConsolidationBeiChi(strokes, zhongShuList, macdHistValues, areaRatioThresholdInput)
    consolidBeiChiSignals.clear()
    for bc in newConsolidBC
        consolidBeiChiSignals.push(bc)

    // 第一类买卖点:趋势背驰 → B1/S1(一一对应)
    newFirstSignals = detectFirstSignals(trendBeiChiSignals, strokes)
    firstSignals.clear()
    for fs in newFirstSignals
        firstSignals.push(fs)

    // 第三类买卖点:中枢边界 → B3/S3
    newThirdSignals = detectThirdSignals(strokes, zhongShuList)
    thirdSignals.clear()
    for ts in newThirdSignals
        thirdSignals.push(ts)

    // 第二类买卖点:B1/S1 → 二次确认 → B2/S2
    newSecondSignals = detectSecondSignals(firstSignals, strokes)
    secondSignals.clear()
    for ss in newSecondSignals
        secondSignals.push(ss)

    // 合并所有信号 → 三路归并(三类信号各自已按 barIndex 有序)
    allRaw = array.new<TradingSignal>()
    fi = 0
    ti = 0
    si = 0
    fn = firstSignals.size()
    tn = thirdSignals.size()
    sn = secondSignals.size()
    infinity = 99999999

    while fi < fn or ti < tn or si < sn
        fb = fi < fn ? firstSignals.get(fi).barIndex : infinity
        tb = showB3Input and ti < tn ? thirdSignals.get(ti).barIndex : infinity
        sb = showB2Input and si < sn ? secondSignals.get(si).barIndex : infinity

        if fb <= tb and fb <= sb
            allRaw.push(firstSignals.get(fi))
            fi += 1
        else if tb <= fb and tb <= sb
            allRaw.push(thirdSignals.get(ti))
            ti += 1
        else
            allRaw.push(secondSignals.get(si))
            si += 1

    filteredAll = filterSignalSpacing(allRaw, minSignalBarsInput)

    allFilteredSignals.clear()
    for fs in filteredAll
        allFilteredSignals.push(fs)

// ============================================================
// VISUALIZATION - TDD 验证
// ============================================================

// --- 1. 包含处理后K线的高低点连线 ---
// O(1) 查表:从预构建的映射表读取合并后高低点
// 映射表在 barstate.islastconfirmedhistory 时一次性构建,之后直接索引访问
var float displayHigh = na
var float displayLow  = na

if bar_index < mergedHighMap.size()
    displayHigh := mergedHighMap.get(bar_index)
    displayLow  := mergedLowMap.get(bar_index)
else
    // 映射表尚未构建(历史加载中)或当前 bar 是实时未确认K线
    displayHigh := high
    displayLow  := low

plot(displayHigh, "合并后高点", color=color.new(color.blue, 60), linewidth=1, trackprice=false)
plot(displayLow,  "合并后低点", color=color.new(color.blue, 60), linewidth=1, trackprice=false)

// --- 2. 笔的画线 ---
// 策略:在 needFullRebuild 时删除所有旧线/旧标签,重新创建。
// 用 var array 跟踪引用,避免重复创建和对象泄漏。
// 向上笔(底→顶)红色实线,向下笔(顶→底)绿色实线。
var array<line>  strokeLines  = array.new<line>()
var array<label> strokeLabels = array.new<label>()

if needFullRebuild
    // 删除旧线
    for oldLine in strokeLines
        oldLine.delete()
    strokeLines.clear()

    // 删除旧标签
    for oldLabel in strokeLabels
        oldLabel.delete()
    strokeLabels.clear()

    // 为每条笔创建线段
    // 使用 barTimes 数组获取时间戳,避免 time[offset] 的 5000 根历史引用限制
    for s in strokes
        lineColor = s.dir == StrokeDirection.UP ? color.red : color.green
        startTime = barTimes.get(s.startOrigIndex)
        endTime   = barTimes.get(s.endOrigIndex)
        newLine = line.new(
          startTime, s.startPrice,
          endTime, s.endPrice,
          color=lineColor,
          width=2,
          xloc=xloc.bar_time
        )
        strokeLines.push(newLine)

    // 笔信息标签(可选):在笔终点显示 K线数和价格变化
    if showBiLabelInput
        for s in strokes
            endTime = barTimes.get(s.endOrigIndex)
            newLabel = label.new(
              endTime,
              s.endPrice,
              str.format("{0}K\n{1,number,#.#}%", s.barCount, s.priceChangePct),
              color=s.dir == StrokeDirection.UP ? color.red : color.green,
              textcolor=color.white,
              style=s.dir == StrokeDirection.UP ? label.style_label_down : label.style_label_up,
              size=size.small,
              xloc=xloc.bar_time
            )
            strokeLabels.push(newLabel)

// --- 3. 中枢可视化 ---
// 策略:在 needFullRebuild 时删除所有旧对象,重新创建。
// var array 跟踪 box/line/label 引用,避免对象泄漏。
// 中枢交替使用蓝色/紫色半透明框,GG/DD 为浅色虚线。
var array<box>   zhongShuBoxes  = array.new<box>()
var array<line>  zhongShuLines  = array.new<line>()
var array<label> zhongShuLabels = array.new<label>()

if needFullRebuild
    // 删除旧对象
    for oldBox in zhongShuBoxes
        oldBox.delete()
    zhongShuBoxes.clear()
    for oldLine in zhongShuLines
        oldLine.delete()
    zhongShuLines.clear()
    for oldLabel in zhongShuLabels
        oldLabel.delete()
    zhongShuLabels.clear()

    // 为每个中枢创建可视化元素
    for idx = 0 to zhongShuList.size() - 1
        zs = zhongShuList.get(idx)

        leftTime  = barTimes.get(zs.startOrigIndex)
        rightTime = barTimes.get(zs.endOrigIndex)

        // 颜色交替:偶数索引用蓝色,奇数用紫色
        boxColor = idx % 2 == 0 ? color.blue : color.purple

        // 修改1: 中枢矩形框(虚线边框 + 半透明填充)
        newBox = box.new(
          leftTime, zs.zg,
          rightTime, zs.zd,
          border_color=boxColor,
          border_width=1,
          border_style=line.style_dashed,
          bgcolor=color.new(boxColor, 85),
          xloc=xloc.bar_time
        )
        zhongShuBoxes.push(newBox)

        // 修改2: GG/DD 延伸虚线(浅色虚线,标记中枢内部实际波动范围)
        ggLine = line.new(
          leftTime, zs.gg,
          rightTime, zs.gg,
          color=color.new(boxColor, 50),
          style=line.style_dashed,
          xloc=xloc.bar_time
        )
        zhongShuLines.push(ggLine)

        ddLine = line.new(
          leftTime, zs.dd,
          rightTime, zs.dd,
          color=color.new(boxColor, 50),
          style=line.style_dashed,
          xloc=xloc.bar_time
        )
        zhongShuLines.push(ddLine)

        // 修改3: 中枢级别标签(右上角显示笔数)
        if showZSLevelInput
            zsLabel = label.new(
              rightTime,
              zs.zg,
              str.format("{0}笔", zs.strokeCount),
              color=boxColor,
              textcolor=color.white,
              style=label.style_label_down,
              size=size.small,
              xloc=xloc.bar_time
            )
            zhongShuLabels.push(zsLabel)

// --- 3b. 背驰信号标记 ---
// 策略:在 needFullRebuild 时删除旧标签,重新创建。
// 趋势背驰:红色"顶背"/绿色"底背"标签 + plotshape 箭头
// 盘整背驰:蓝色/橙色较小标签,独立开关控制
var array<label> beiChiLabels = array.new<label>()

if needFullRebuild
    for oldLabel in beiChiLabels
        oldLabel.delete()
    beiChiLabels.clear()

    // 趋势背驰标记
    if showBeiChiInput
        for bc in trendBeiChiSignals
            bcTime = barTimes.get(bc.barIndex)
            isTop = bc.bcType == BeiChiType.TOP
            bcColor = isTop ? color.red : color.green
            bcText  = isTop ? "顶背" : "底背"
            bcStyle = isTop ? label.style_label_down : label.style_label_up
            // 用 stroke.endPrice 获取价格,避免 high[offset] 的历史引用限制
            bcPrice = strokes.get(bc.strokeIndex).endPrice
            bcLabel = label.new(
              bcTime,
              bcPrice,
              bcText,
              color=bcColor,
              textcolor=color.white,
              style=bcStyle,
              size=size.small,
              xloc=xloc.bar_time
            )
            beiChiLabels.push(bcLabel)

    // 盘整背驰标记(可选,默认关闭)
    if showConsolidationBCInput
        for bc in consolidBeiChiSignals
            bcTime = barTimes.get(bc.barIndex)
            isTop = bc.bcType == BeiChiType.TOP
            bcColor = isTop ? color.blue : color.orange
            bcText  = isTop ? "盘顶背" : "盘底背"
            bcStyle = isTop ? label.style_label_down : label.style_label_up
            bcPrice = strokes.get(bc.strokeIndex).endPrice
            bcLabel = label.new(
              bcTime,
              bcPrice,
              bcText,
              color=bcColor,
              textcolor=color.white,
              style=bcStyle,
              size=size.tiny,
              xloc=xloc.bar_time
            )
            beiChiLabels.push(bcLabel)

// --- 4b. 买卖点信号标记 ---
// 策略:与背驰/中枢可视化一致,needFullRebuild 时删除重建。
// B1/B2/B3 绿色系,S1/S2/S3 红色系,强度递减(透明度递增)。
// 用 TradingSignal.description 作为标签文字。
var array<label> signalLabels = array.new<label>()

if needFullRebuild
    for oldLabel in signalLabels
        oldLabel.delete()
    signalLabels.clear()

    // M4: 趋势状态(依赖 strokes,循环中不变,只算一次)
    string trendState = "盘整"
    if showSignalQualityInput
        trendState := getTrendState(strokes, 5)

    for sig in allFilteredSignals
        // 买点/卖点总开关
        isBuy  = sig.sType == SignalType.B1 or sig.sType == SignalType.B2 or sig.sType == SignalType.B3
        isSell = sig.sType == SignalType.S1 or sig.sType == SignalType.S2 or sig.sType == SignalType.S3

        if (isBuy and not showBuySignalsInput) or (isSell and not showSellSignalsInput)
            continue

        sigTime = barTimes.get(sig.barIndex)
        sigPrice = sig.price

        // 颜色:绿色系买点,红色系卖点,强度越强越不透明
        sigColor = switch sig.sType
            SignalType.B1 => color.green
            SignalType.B2 => color.new(color.green, 25)
            SignalType.B3 => color.new(color.green, 55)
            SignalType.S1 => color.red
            SignalType.S2 => color.new(color.red, 25)
            SignalType.S3 => color.new(color.red, 55)

        sigStyle = isBuy ? label.style_label_up : label.style_label_down

        // M4: 信号质量评分
        int qualityScore = 0
        if showSignalQualityInput
            qualityScore := scoreSignal(sig.sType, sig.refIndex, sig.barIndex,
              trendBeiChiSignals, zhongShuList, trendState)

        // M4: MA 趋势过滤
        bool maFiltered = false
        if showMATrendFilterInput
            if (isBuy and close <= maTrendValue) or (isSell and close >= maTrendValue)
                maFiltered := true

        // M4: 颜色覆盖(MA 过滤 → 整体变灰)
        if maFiltered
            sigColor := color.new(color.gray, 50)

        // M4: 最低质量阈值 + MA 过滤守卫
        bool shouldRender = true
        if showSignalQualityInput and minSignalQualityInput > 0 and qualityScore < minSignalQualityInput
            shouldRender := false

        if shouldRender
            // M3: 计算止损价
            float slPrice = na
            if showStopLossInput
                refPrice = switch sig.sType
                    SignalType.B2 =>
                        float b1Price = na
                        for fs in firstSignals
                            if fs.sType == SignalType.B1 and fs.refIndex == sig.refIndex
                                b1Price := fs.price
                                break
                        b1Price
                    SignalType.S2 =>
                        float s1Price = na
                        for fs in firstSignals
                            if fs.sType == SignalType.S1 and fs.refIndex == sig.refIndex
                                s1Price := fs.price
                                break
                        s1Price
                    => float(na)

                zd = sig.refIndex < zhongShuList.size() ? zhongShuList.get(sig.refIndex).initialZD : na
                zg = sig.refIndex < zhongShuList.size() ? zhongShuList.get(sig.refIndex).initialZG : na

                slPrice := getStopLossForSignal(sig.sType, sig.price, refPrice, zd, zg, atrCurrent)

            // M3+M4: 构建标签文字
            sigTypeName = switch sig.sType
                SignalType.B1 => "B1"
                SignalType.B2 => "B2"
                SignalType.B3 => "B3"
                SignalType.S1 => "S1"
                SignalType.S2 => "S2"
                SignalType.S3 => "S3"

            // 第一行:信号类型 [+ ★评分] [+ MA过滤标记]
            string line1 = sigTypeName
            if showSignalQualityInput
                line1 := str.format("★{0} {1}", qualityScore, sigTypeName)
            if maFiltered
                line1 := str.format("{0} [MA过滤]", line1)

            // 第二行:价格
            string line2 = str.tostring(math.round_to_mintick(sig.price))

            // 组装
            labelText = str.format("{0}\n{1}", line1, line2)
            if showStopLossInput and not na(slPrice)
                labelText := str.format("{0}\n止损 {1,number,#.##}", labelText, slPrice)

            sigLabel = label.new(
              sigTime,
              sigPrice,
              labelText,
              color=sigColor,
              textcolor=color.white,
              style=sigStyle,
              size=size.small,
              xloc=xloc.bar_time
            )
            signalLabels.push(sigLabel)

// --- 4c. 分型文字标签 ---
// 策略:与笔线/中枢/背驰/信号标签一致,needFullRebuild 时删除重建。
// plotshape 三角标记保持声明式(不泄漏)。
if needFullRebuild
    for oldLabel in fractalLabels
        oldLabel.delete()
    fractalLabels.clear()

    if showFractalLabels
        for f in fractals
            fTime = barTimes.get(f.originalIndex)
            if f.fType == FractalType.TOP
                fractalLabels.push(label.new(
                  fTime, f.price,
                  "顶",
                  style=label.style_label_down,
                  color=color.red,
                  textcolor=color.white,
                  size=size.small,
                  xloc=xloc.bar_time,
                  yloc=yloc.abovebar
                ))
            else if f.fType == FractalType.BOTTOM
                fractalLabels.push(label.new(
                  fTime, f.price,
                  "底",
                  style=label.style_label_up,
                  color=color.green,
                  textcolor=color.white,
                  size=size.small,
                  xloc=xloc.bar_time,
                  yloc=yloc.belowbar
                ))

// 清理调试标签:每次 needFullRebuild 时清空旧标签。
// 注意:清空后历史 bar 的调试标签不会自动重建(per-bar 逻辑只对当前 bar_index 生效),
// 这是调试模式的已知限制——关闭并重新开启调试模式即可恢复完整显示。
if needFullRebuild
    for oldLabel in debugMergeLabels
        oldLabel.delete()
    debugMergeLabels.clear()

// --- 预判计算(统一入口:实时 bar 首次 tick + 历史加载完成时各算一次,面板和告警复用)---
if barstate.isrealtime or barstate.islastconfirmedhistory
    // barstate.isnew:每个新 bar 首次执行为 true;barstate.islastconfirmedhistory:历史加载完成
    // 缓存失效仅由这两个事件触发,删除了原来的 else 重置分支
    if barstate.isnew or barstate.islastconfirmedhistory
        predictionsCached := false

    if not predictionsCached
        cachedPredictions.clear()
        newPredictions = computePredictions(strokes, zhongShuList, trendBeiChiSignals, allFilteredSignals)
        for p in newPredictions
            cachedPredictions.push(p)
        predictionsCached := true

// --- 5b. 实时预判面板(表格)---
// 用 table.new() 在图表右上角创建固定列表格。
// 在实时 bar 和历史加载完成时显示。非交易时段使用缓存数据。
// 预分配 7 列 × 8 行(1 表头 + 6 数据行 + 1 时间戳行),Pine Script 不支持动态 resize。
var table predictTable = table.new(position.top_right, 7, 8,
    border_width=1, border_color=color.gray,
    frame_width=1, frame_color=color.gray,
    bgcolor=color.new(color.black, 90))

// 面板显示条件:实时 bar + 历史加载完成时
showPanel = showPredictionInput and (barstate.isrealtime or barstate.islastconfirmedhistory)
isNonTrading = showPanel and not barstate.isrealtime

if showPanel
    // 使用缓存的预判结果(已在统一入口计算)

    // 表头(列 4/5 在 showStopLossInput=false 时留空,视觉退化为 5 列)
    headerSignalText = isNonTrading ? "信号 [非交易时段]" : "信号"
    table.cell(predictTable, 0, 0, headerSignalText, text_color=color.white, bgcolor=color.gray)
    table.cell(predictTable, 1, 0, "触发价", text_color=color.white, bgcolor=color.gray)
    table.cell(predictTable, 2, 0, "当前价", text_color=color.white, bgcolor=color.gray)
    table.cell(predictTable, 3, 0, "距离%",  text_color=color.white, bgcolor=color.gray)
    table.cell(predictTable, 4, 0, showStopLossInput ? "止损位"  : "", text_color=color.white, bgcolor=showStopLossInput ? color.gray : color.new(color.black, 90))
    table.cell(predictTable, 5, 0, showStopLossInput ? "距止损%" : "", text_color=color.white, bgcolor=showStopLossInput ? color.gray : color.new(color.black, 90))
    table.cell(predictTable, 6, 0, "触发条件", text_color=color.white, bgcolor=color.gray)

    // 填充预判行(最多 6 行)
    maxRows = math.min(cachedPredictions.size(), 6)
    if maxRows > 0
        for row = 0 to maxRows - 1
            p = cachedPredictions.get(row)
            currentPrice = close

            isBuy = p.signalType == "B1" or p.signalType == "B2" or p.signalType == "B3"
            rowColor = isBuy ? color.new(color.green, 80) : color.new(color.red, 80)

            table.cell(predictTable, 0, row + 1, p.signalType,
              text_color=color.white, bgcolor=rowColor)
            table.cell(predictTable, 1, row + 1, str.tostring(math.round_to_mintick(p.triggerPrice)),
              text_color=color.white, bgcolor=rowColor)

            // 列 2: 当前价(非交易时段标注"收盘")
            string priceText = str.tostring(math.round_to_mintick(currentPrice))
            if isNonTrading
                priceText := str.format("{0} (收盘)", priceText)
            table.cell(predictTable, 2, row + 1, priceText,
              text_color=color.white, bgcolor=rowColor)

            // 列 3: 距离%(非交易时段显示 --)
            string distText = ""
            color  distColor = color.gray
            if isNonTrading
                distText := "--"
            else
                dist = (p.triggerPrice - currentPrice) / currentPrice * 100.0
                distText := str.format("{0,number,#.#}%", dist)
                distColor := dist > 0 ? color.green : color.red
            table.cell(predictTable, 3, row + 1, distText,
              text_color=distColor, bgcolor=rowColor)

            // 列 4: 止损位
            if showStopLossInput and not na(p.stopLoss)
                table.cell(predictTable, 4, row + 1, str.tostring(math.round_to_mintick(p.stopLoss)),
                  text_color=color.white, bgcolor=rowColor)
            else
                table.cell(predictTable, 4, row + 1, "", bgcolor=color.new(color.black, 90))

            // 列 5: 距止损%
            if showStopLossInput and not na(p.stopLoss)
                slDist = (currentPrice - p.stopLoss) / currentPrice * 100.0
                slDistText = str.format("{0,number,#.#}%", slDist)
                slDistColor = slDist > 0 ? color.green : color.red
                table.cell(predictTable, 5, row + 1, slDistText,
                  text_color=slDistColor, bgcolor=rowColor)
            else
                table.cell(predictTable, 5, row + 1, "", bgcolor=color.new(color.black, 90))

            // 列 6: 触发条件(原列 4)
            condText = str.format("{0} {1}", p.statusText, p.description)
            table.cell(predictTable, 6, row + 1, condText,
              text_color=color.white, bgcolor=rowColor)

    // 清空未使用预测行(row 1-6,透明背景不遮挡图表)
    if maxRows <= 5
        for row = maxRows to 5
            for col = 0 to 6
                table.cell(predictTable, col, row + 1, "", bgcolor=na)

    // 更新时间戳行(row 7,永远填充,不需要清理)
    string timeText = str.format("更新: {0,time,HH:mm:ss}", timenow)
    if isNonTrading
        timeText := str.format("更新: {0,time,yyyy-MM-dd HH:mm}", time_close)
    table.cell(predictTable, 0, 7, timeText,
      text_color=color.new(color.white, 70), bgcolor=color.new(color.black, 70))
    for col = 1 to 6
        table.cell(predictTable, col, 7, "", bgcolor=color.new(color.black, 70))

// --- 5c. Alert 告警条件 ---
// 在当前价接近任意预判触发价时发送告警。
// alert.freq_once_per_bar_close 确保每根 bar 每种信号最多告警一次。
if barstate.isrealtime
    for p in cachedPredictions
        // 已触发的预判不再告警(信号标签已显示,无需重复提醒)
        if p.statusText == "✅ 已触发"
            continue

        distPct = math.abs(close - p.triggerPrice) / p.triggerPrice * 100.0
        if distPct <= alertThresholdInput
            alert(
              str.format("{0} 接近触发!触发价={1} 当前价={2} 偏差={3,number,#.#}%",
                p.signalType,
                math.round_to_mintick(p.triggerPrice),
                math.round_to_mintick(close),
                distPct
              ),
              alert.freq_once_per_bar_close
            )

// --- 7. 分型标记 ---
// 顶分型:红色▼三角 + "顶"文字标签,画在K线上方
// 底分型:绿色▲三角 + "底"文字标签,画在K线下方
// plotshape 提供轻量级三角标记,label.new 提供清晰的中文文字
isTopFractal    = false
isBottomFractal = false

for f in fractals
    if f.originalIndex == bar_index
        isTopFractal    := f.fType == FractalType.TOP
        isBottomFractal := f.fType == FractalType.BOTTOM
        break

plotshape(isTopFractal, "顶分型", shape.triangledown, location.abovebar, color.red, size=size.small, offset=0)
plotshape(isBottomFractal, "底分型", shape.triangleup, location.belowbar, color.green, size=size.small, offset=0)

// --- 8. 调试模式:包含处理前后对比 ---
// 关闭调试模式时,所有辅助元素隐藏,不影响正常看图
//
// 原始K线高低点(半透明点状,用于与合并后蓝色实线对比)
plot(
  showContainmentDebug ? high : na,
  "原始高点",
  color=color.new(color.gray, 75),
  linewidth=1,
  style=plot.style_circles,
  trackprice=false
)
plot(
  showContainmentDebug ? low : na,
  "原始低点",
  color=color.new(color.gray, 75),
  linewidth=1,
  style=plot.style_circles,
  trackprice=false
)

// O(1) 查表:从预构建映射表读取当前 bar 的合并信息
// bgcolor 必须在全局作用域,label 只在条件满足时创建
bool isMergedBarBg = false

if showContainmentDebug and bar_index < mergedCountMap.size()
    mergedCnt = mergedCountMap.get(bar_index)
    if mergedCnt > 1
        isMergedBarBg := true

        // 在合并K线最后一根原始K线位置画"X合"标签
        // 判断是否为最后一根:检查下一根 bar 的 mergeCount 是否与当前相同
        isLastOfMerge = bar_index + 1 >= mergedCountMap.size() or mergedCountMap.get(bar_index + 1) != mergedCnt
        if isLastOfMerge
            newDbgLabel = label.new(
              bar_index,
              mergedHighMap.get(bar_index),
              str.format("{0}合", mergedCnt),
              color=color.orange,
              textcolor=color.white,
              style=label.style_label_down,
              size=size.small
            )
            debugMergeLabels.push(newDbgLabel)

bgcolor(isMergedBarBg ? color.new(color.orange, 88) : na, title="被合并K线")

// --- 9. 统计信息日志(TDD:M1-M6 全链路验证)---
if barstate.islastconfirmedhistory
    rawCount      = bar_index + 1
    mergedCount   = mergedBars.size()
    mergeRate     = 100.0 * (rawCount - mergedCount) / rawCount
    fractalCount  = fractals.size()
    strokeCount   = strokes.size()
    zsCount       = zhongShuList.size()
    trendBCCount  = trendBeiChiSignals.size()
    consolidBCCount = consolidBeiChiSignals.size()
    b1Count = 0
    b2Count = 0
    b3Count = 0
    s1Count = 0
    s2Count = 0
    s3Count = 0
    for sig in allFilteredSignals
        if sig.sType == SignalType.B1
            b1Count += 1
        else if sig.sType == SignalType.B2
            b2Count += 1
        else if sig.sType == SignalType.B3
            b3Count += 1
        else if sig.sType == SignalType.S1
            s1Count += 1
        else if sig.sType == SignalType.S2
            s2Count += 1
        else if sig.sType == SignalType.S3
            s3Count += 1

    log.info("=== M1-M5 全链路统计 ===")
    log.info("  K线: {0} → 合并: {1} ({2,number,#.#}%)", rawCount, mergedCount, mergeRate)
    log.info("  分型: {0} → 笔: {1} → 中枢: {2}", fractalCount, strokeCount, zsCount)
    log.info("  趋势背驰: {0} → 盘整背驰: {1}", trendBCCount, consolidBCCount)
    log.info("  买点: B1={0} B2={1} B3={2} | 卖点: S1={3} S2={4} S3={5}",
             b1Count, b2Count, b3Count, s1Count, s2Count, s3Count)

    // 统计连续同向分型(原始分型允许同向——这是修复后的设计意图,
    // 分型交替不再是全局不变量,同向选择已内化到 buildBi() 内部)
    sameAdjacent = 0
    for j = 1 to fractals.size() - 1
        if fractals.get(j).fType == fractals.get(j - 1).fType
            sameAdjacent += 1
    if fractalCount > 0
        log.info("  连续同向分型: {0} 对(同向选择由 buildBi 处理)", sameAdjacent)

    // 验证笔方向交替(M2 TDD 检查点)
    biAltOk = true
    for j = 1 to strokes.size() - 1
        if strokes.get(j).dir == strokes.get(j - 1).dir
            biAltOk := false
            log.error("笔方向交替异常: 索引 {0} 和 {1} 同向", j-1, j)
    if biAltOk and strokeCount > 0
        log.info("  ✓ 笔方向交替检查通过")

    // 验证笔时间顺序(防御回归:两阶段算法保证 startOrigIndex < endOrigIndex,
    // 但日志段必须能捕获未来任何破坏此不变量的改动)
    timeOrderOk = true
    for j = 0 to strokes.size() - 1
        if strokes.get(j).startOrigIndex >= strokes.get(j).endOrigIndex
            timeOrderOk := false
            log.error("笔时间顺序异常: 索引 {0} start={1} end={2}", j, strokes.get(j).startOrigIndex, strokes.get(j).endOrigIndex)
    if timeOrderOk and strokeCount > 0
        log.info("  ✓ 笔时间顺序检查通过(startOrigIndex < endOrigIndex)")

    // 验证中枢合法性(M3 TDD 检查点)
    zsOk = true
    for j = 0 to zhongShuList.size() - 1
        zs = zhongShuList.get(j)
        if zs.strokeCount < 3
            zsOk := false
            log.error("中枢笔数不足: 中枢 {0} 只有 {1} 笔", j, zs.strokeCount)
        if zs.zg <= zs.zd
            zsOk := false
            log.error("中枢重叠区间无效: 中枢 {0} ZG={1} <= ZD={2}", j, zs.zg, zs.zd)
    if zsOk and zsCount > 0
        log.info("  ✓ 中枢合法性检查通过(笔数≥3, ZG>ZD)")

    // 检查相邻中枢价格重叠(扩展中枢 vs 新生中枢)
    overlapCount = 0
    for j = 1 to zhongShuList.size() - 1
        prevZS = zhongShuList.get(j - 1)
        currZS = zhongShuList.get(j)
        // 如果前一个中枢的 ZG > 当前中枢的 ZD 且 当前中枢的 ZG > 前一个中枢的 ZD
        // 则两个中枢在价格上有重叠 → 可能构成扩展中枢
        if prevZS.zg > currZS.zd and currZS.zg > prevZS.zd
            overlapCount := overlapCount + 1
    if overlapCount > 0
        log.info("  相邻中枢价格重叠: {0} 处(可能为中枢扩展)", overlapCount)

Overview

缠论 - 分型与笔 [Chanlun Core] merges contained bars, marks top and bottom fractals, and connects alternating fractals into strokes. Overlapping strokes form pivot boxes, and MACD area ratios flag trend or consolidation divergence. It labels three classes of buy and sell points, with optional minimum spacing, quality scores, and an ATR-based stop reference. A prediction panel and an optional moving-average trend filter can hide countertrend marks. It does not place orders.

Details

Published by

Ye-Yu-Mo on GitHub

Type

Indicator, Pine Script v6

Draws

On the price chart

Category

Support & resistance, Divergence, Oscillators & momentum, Screeners & dashboards

Markets

Stocks

Timeframes

Any timeframe

Risk Notes

No repainting, lookahead or stacked entries were found in its code or README. That says nothing about how well it works: test it first.

Topics

candlestick-patternschanlunchina-stockspine-scriptquantitative-tradingstock-markettechnical-analysistrading-indicatortradingviewzhongshu

Settings

The inputs you can change from the gear icon on the chart. Defaults are the author's.

SettingTypeDefaultVariable
显示包含处理过程boolfalseshowContainmentDebug
显示分型文字标签booltrueshowFractalLabels
显示 buildBi 诊断日志boolfalseshowBuildBiDebugInput
笔最少K线数 (含分型)int5minBarsForBiInput
笔最小价格变化 (%)float0.0minBiPriceChangeInput
显示笔信息标签boolfalseshowBiLabelInput
中枢最少笔数int3minStrokesForZSInput
中枢最小宽度 (%)float0.0minZSWidthInput
显示中枢级别boolfalseshowZSLevelInput
MACD 快线int12macdFastInput
MACD 慢线int26macdSlowInput
MACD 信号线int9macdSignalInput
Show all 30 settings
SettingTypeDefaultVariable
MACD 绝对值面积boolfalsemacdAbsoluteInput
背驰面积比阈值float0.8areaRatioThresholdInput
显示背驰信号booltrueshowBeiChiInput
显示盘整背驰boolfalseshowConsolidationBCInput
显示买点 (B1/B2/B3)booltrueshowBuySignalsInput
显示卖点 (S1/S2/S3)booltrueshowSellSignalsInput
同类型信号最小间距 (K线)int10minSignalBarsInput
显示第二类买卖点 (B2/S2)booltrueshowB2Input
显示第三类买卖点 (B3/S3)booltrueshowB3Input
显示预判面板booltrueshowPredictionInput
告警价格偏差 (%)float0.5alertThresholdInput
显示止损信息booltrueshowStopLossInput
ATR 周期int14stopLossAtrPeriodInput
ATR 倍数float1.5stopLossAtrMultiplierInput
显示信号质量评分booltrueshowSignalQualityInput
最低信号质量int0minSignalQualityInput
启用 MA 趋势过滤boolfalseshowMATrendFilterInput
MA 趋势周期int60maTrendPeriodInput
Third-party code. ForexR did not write, test or endorse this script, and the summary above was generated automatically from its code and README. Indicators describe the past, and a strategy's backtest can look better than live trading, especially when a script repaints. Check it in the Strategy Tester and on a demo account before you rely on it.

Similar Pine Scripts

Pivot Momentum Structure plots a selectable oscillator in a separate pane and scores momentum against confirmed price pivots. It classifies higher highs, higher lows, lower highs and lower lows, then marks regular and hidden bullish or bearish divergences plus

Indicator v6 Oscillators & momentumDivergence
Updated yesterday · MIT GitHub repository of Pivot Momentum Structure [WavesUnchained]
23 stars

This indicator collection calculates and plots previous daily high and low price levels on the chart to identify potential supply and demand zones. It includes real-time intraday high and low calculations, RSI divergence detection across a customizable period

Indicator v4 Support & resistanceDivergence
Updated 3 years ago · None stated GitHub repository of Daily High and Low Strategy

Bandz HTF

by samuelharting
18 stars

Bandz HTF projects higher-timeframe candle structures, ranges, and key levels directly onto the main price chart. It visualizes multiple configurable timeframe lanes from intraday to weekly alongside primary and macro range levels, including 25%, 50%, and 75%

Indicator v6 SMC / ICT / price actionSupport & resistance
Updated 2 weeks ago · MPL-2.0 GitHub repository of Bandz HTF

This indicator projects a dual-asset momentum matrix visualizer directly onto the price chart overlay. It calculates normalized momentum for the chart symbol using either RSI or Stochastic inputs adjusted by ATR volatility, and compares it against a benchmark

Indicator v6 Oscillators & momentumVolatility & bands Repaints
Updated 3 months ago · None stated GitHub repository of Pro Grid Elite: Advanced Dual-Asset Momentum Matrix
5 stars

This indicator plots RSI(14) on the price chart along with regular and hidden bullish and bearish divergences. It detects divergences by comparing price highs and lows to RSI values using pivot points with configurable lookback bars. Signals appear as triangle

Indicator v4 Oscillators & momentumDivergence
Updated 2 years ago · None stated GitHub repository of CB RSI (Divergence and OB/OS)

More indicators

Trade Your Charts With a Regulated Broker

Compare brokers that connect to TradingView, with spreads, minimum deposits and leverage side by side.

Compare brokers