Indicator · Pine Script v6 · On the chart

加密货币1小时关键价位热力图 V2

by NeuNue

This indicator identifies and visualizes key support and resistance heatmap zones directly on the price chart.

View on GitHub

GitHub stars11on the repository
Pine versionv6current
Lines of code1,504
Settings20inputs
LicenceMIT
Last update7 weeks ago8 Sep 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

1,504 lines of Pine Script v6 from key_level_heatmap/key_level_heatmap_crypto.pine, published under MIT. Keep the author's credit and licence lines when you reuse it.

key_level_heatmap_crypto.pine
//@version=6

// =============================================================================
// 加密货币 1H 关键价位热力图 V2(自适应验证版)
// =============================================================================
// 本脚本是 key_level_heatmap_crypto.pine 的迭代优化版。原始文件未做修改。
//
// V2 针对加密货币 7×24、高波动、杠杆清算频繁的微观结构做了五类改进:
//
// 1. 多尺度枢轴确认
//    - 同时检查短腿枢轴与长腿枢轴,多尺度共振的价位会获得更高证据分;
//    - 避免单一固定腿数在趋势/震荡切换时漏掉真正结构位。
//
// 2. 流动性集群 / 等高点等低点识别
//    - 对已确认摆动高/低点做 ATR 自适应聚类;
//    - 双重顶/底、三重顶/底、清算池等价位会获得额外证据;
//    - 这是原版缺口+固定枢轴无法识别的加密特色结构。
//
// 3. 触及后“验证制”计分
//    - 不再把每次穿越都按固定分累加,而是观察 4 根 1H K 线:
//      有效拒绝(反向位移 >= 0.35 ATR)才计入 validReaction;
//      无效穿越(继续朝不利方向 >= 0.65 ATR)会降低该区间权重。
//    - 分数从“触及次数”升级为“被验证的可交易反应质量”。
//
// 4. 突破失效惩罚与角色翻转保留
//    - 收盘有效突破后更果断地削弱旧支撑/阻力;
//    - 若后续回踩重新验证原突破边,仍可凭多空转换证据复活。
//
// 5. 更严格的评分、合并与显示
//    - 整数位只放大“已有价格证据”的区间,不再凭空制造整数位;
//    - 合并半径 0.45 -> 0.38 ATR 带宽,中心价更贴近真实反应点;
//    - 距离分权重 25% -> 18%,降低“离得近但无结构”的干扰;
//    - 默认最大显示距离 14 ATR -> 10 ATR,减少远端过期杂带。
//
// 使用方式与原版一致:固定 1 小时证据,图表周期 1 分钟~1 小时,
// 实时盘中使用已收盘确认区间,不建议按历史绘图做自动回测。
//
// 默认配置(已验证优先):权重 45 / 25 / 20 / 5 / 5;
// 最低强度 40;默认只显示至少出现过一次有效拒绝反应的区间。
// =============================================================================

indicator(
     "加密货币1小时关键价位热力图 V2",
     shorttitle = "加密关键区V2",
     overlay = true,
     max_boxes_count = 12,
     max_lines_count = 12,
     max_labels_count = 12
)

// 固定参数
const string ANALYSIS_TIMEFRAME = "60"
const int MAX_DRAW_ZONES = 12
const int MAX_ENGINE_ZONES = 170
const int CALC_BARS_COUNT = 5200
const int MIN_HISTORY_DAYS = 75
// 时间衰减:加密货币全天交易、波动更大。V2 短期半衰期约 9.5 个日历日,
// 长期约 31 个日历日,比 V1 更偏向仍被近期验证的证据。
const float SHORT_DAILY_DECAY = 0.9300
const float LONG_DAILY_DECAY = 0.9780
// V2 降低距离分权重,结构证据成为主导项。
const float STRUCTURE_RANK_WEIGHT = 0.82
const float DISTANCE_RANK_WEIGHT = 0.18
const float DISTANCE_DECAY_ATR = 7.0
const float STRUCTURE_FLOOR_RATIO = 0.40

// 输入分组
const string GROUP_CORE = "核心设置"
const string GROUP_ROUND = "整数位置"
const string GROUP_DISPLAY = "显示设置"
const string GROUP_ADVANCED = "高级权重"

string sensitivityInput = input.string(
     "标准",
     "灵敏度",
     options = ["保守", "标准", "密集"],
     tooltip = "保守模式减少候选区;密集模式保留更多局部结构。V2 会在该档位基础上再叠加长腿枢轴确认。",
     group = GROUP_CORE
)
int maxZonesInput = input.int(
     8,
     "最大区间数",
     minval = 4,
     maxval = MAX_DRAW_ZONES,
     tooltip = "限制图上同时显示的高强度区间数量。",
     group = GROUP_CORE
)
float minStrengthInput = input.float(
     40.0,
     "最低强度",
     minval = 0.0,
     maxval = 100.0,
     step = 1.0,
     tooltip = "低于该可交易性分数的区间不会显示。V2 分数以已验证反应质量为主,默认门槛比 V1 高 5 分。",
     group = GROUP_CORE
)
float displayMergeAtrInput = input.float(
     0.70,
     "邻近区间合并距离(ATR)",
     minval = 0.25,
     maxval = 1.50,
     step = 0.05,
     tooltip = "显示前按1小时ATR合并相邻微区间。V2 建议 0.60~0.80,比 V1 更窄以减少中心价偏移。",
     group = GROUP_CORE
)
float minSpacingAtrInput = input.float(
     2.25,
     "最小显示间距(ATR)",
     minval = 0.0,
     maxval = 4.0,
     step = 0.05,
     tooltip = "已显示区间中心之间的最小距离。V2 默认 2.25 ATR,减少相邻价位互相干扰;设为0可禁用。",
     group = GROUP_CORE
 )
string widthInput = input.string(
     "标准",
     "区间宽度",
     options = ["窄", "标准", "宽"],
     tooltip = "区间宽度由事件发生时的1小时ATR决定。V2 标准宽度为 0.28 ATR。",
     group = GROUP_CORE
)
bool liquidityClustersInput = input.bool(
     true,
     "流动性集群识别",
     tooltip = "识别双重顶/底、三重顶/底等清算池结构,并为这些价位提供额外证据。",
     group = GROUP_CORE
)
bool validatedOnlyInput = input.bool(
     true,
     "仅显示已验证区间",
     tooltip = "默认开启:只显示历史上至少出现过一次有效拒绝反应的区间,显著减少假位。",
     group = GROUP_CORE
)

string roundModeInput = input.string(
     "自动",
     "整数步长",
     options = ["自动", "手动"],
     tooltip = "自动模式会按标的价格选择适合的整数网格。",
     group = GROUP_ROUND
)
float manualRoundStepInput = input.float(
     5.0,
     "手动基础步长",
     minval = 0.01,
     step = 0.5,
     tooltip = "仅在整数步长选择手动时生效。",
     group = GROUP_ROUND
)

bool showCenterInput = input.bool(
     true,
     "显示中心线",
     tooltip = "中心线用于和人工标注价位比较,热力区间本身才是有效范围。",
     group = GROUP_DISPLAY
)
bool showLabelsInput = input.bool(
     true,
     "显示价位与强度",
     tooltip = "右侧标签格式为:中心价 | 可交易性分数。",
     group = GROUP_DISPLAY
)
string labelSizeInput = input.string(
     "中",
     "标签字号",
     options = ["小", "中", "大"],
     tooltip = "右侧价格与强度标签的文字大小,看不太清时可以换更大的字号。",
     group = GROUP_DISPLAY
 )
float maxDistanceAtrInput = input.float(
     10.0,
     "最大显示距离(ATR)",
     minval = 2.0,
     maxval = 30.0,
     step = 1.0,
     tooltip = "隐藏距离现价过远的历史区间。V2 默认 10 ATR,聚焦可交易距离内的关键位。",
     group = GROUP_DISPLAY
)
color heatColorInput = input.color(
     color.rgb(91, 76, 255),
     "热力颜色",
     tooltip = "颜色只表达关键性强弱,不表达上涨或下跌方向。",
     group = GROUP_DISPLAY
)

float touchWeightInput = input.float(
     45.0,
     "触及与拒绝权重",
     minval = 0.0,
     maxval = 100.0,
     step = 1.0,
     group = GROUP_ADVANCED
)
float roleWeightInput = input.float(
     25.0,
     "多空转换权重",
     minval = 0.0,
     maxval = 100.0,
     step = 1.0,
     group = GROUP_ADVANCED
)
float volumeWeightInput = input.float(
     20.0,
     "相对成交量权重",
     minval = 0.0,
     maxval = 100.0,
     step = 1.0,
     group = GROUP_ADVANCED
)
float gapWeightInput = input.float(
     5.0,
     "缺口权重",
     minval = 0.0,
     maxval = 100.0,
     step = 1.0,
     group = GROUP_ADVANCED
)
float roundWeightInput = input.float(
     5.0,
     "整数共振权重",
     minval = 0.0,
     maxval = 100.0,
     step = 1.0,
     group = GROUP_ADVANCED
)

// 区间保存各类证据。V2 增加触及计数与验证计数,用于计算“反应质量”。
type Zone
    float center
    float halfWidth
    float centerWeight
    float touchShort
    float touchLong
    float roleShort
    float roleLong
    float volumeShort
    float volumeLong
    float gapShort
    float gapLong
    int firstTime
    int lastEventTime
    int lastTouchBar
    int lastTouchDay
    int pendingTouchBar
    int pendingTouchSide
    bool pendingRole
    int lastRelation
    int lastRelationBar
    int lastBreakSide
    int lastBreakBar
    bool gapActive
    int gapDirection
    float gapFillPrice
    int touchCount
    int validReactionCount
    int failedReactionCount

// 计算上下文只向主图返回绘图所需的数据
type ZoneSnapshot
    array<float> centers
    array<float> halfWidths
    array<float> scores
    array<int> firstTimes
    float atr
    float sourceClose
    float liveClose
    int processedDays
    bool volumeAvailable

f_clamp(float value, float lower, float upper) =>
    math.max(lower, math.min(upper, value))

f_combined(float shortValue, float longValue) =>
    0.70 * shortValue + 0.30 * longValue

f_total_evidence(Zone zone) =>
    f_combined(zone.touchShort, zone.touchLong) +
     f_combined(zone.roleShort, zone.roleLong) +
     f_combined(zone.volumeShort, zone.volumeLong) +
     f_combined(zone.gapShort, zone.gapLong)

f_add_evidence(Zone zone, float touchValue, float roleValue, float volumeValue, float gapValue) =>
    zone.touchShort += touchValue
    zone.touchLong += touchValue
    zone.roleShort += roleValue
    zone.roleLong += roleValue
    zone.volumeShort += volumeValue
    zone.volumeLong += volumeValue
    zone.gapShort += gapValue
    zone.gapLong += gapValue
    0.0

f_decay_zone(Zone zone) =>
    zone.touchShort *= SHORT_DAILY_DECAY
    zone.touchLong *= LONG_DAILY_DECAY
    zone.roleShort *= SHORT_DAILY_DECAY
    zone.roleLong *= LONG_DAILY_DECAY
    zone.volumeShort *= SHORT_DAILY_DECAY
    zone.volumeLong *= LONG_DAILY_DECAY
    zone.gapShort *= SHORT_DAILY_DECAY
    zone.gapLong *= LONG_DAILY_DECAY
    zone.centerWeight *= LONG_DAILY_DECAY
    0.0

// 触及反应质量:验证过的拒绝越多,分数越高;只被穿越但从未拒绝的区间降权。
f_reaction_quality(Zone zone) =>
    zone.touchCount > 0
         ? (zone.validReactionCount + 1.0) / (zone.touchCount + 2.0)
         : 0.50

f_event_half_width(float price, float eventAtr, float widthFactor) =>
    float rawWidth = eventAtr * widthFactor
    // 加密货币单根K线波动更大,放宽上下限以避免区间被压得过窄
    float minWidth = math.max(price * 0.0015, syminfo.mintick * 5.0)
    float maxWidth = math.max(price * 0.0060, minWidth)
    f_clamp(rawWidth, minWidth, maxWidth)

f_auto_round_steps(float price, float manualStep, bool useManual) =>
    float minorStep = na
    float majorStep = na
    float superStep = na
    if useManual
        minorStep := manualStep
        majorStep := manualStep * 2.0
        superStep := manualStep * 10.0
    else if price < 1.0
        minorStep := 0.01
        majorStep := 0.05
        superStep := 0.10
    else if price < 10.0
        minorStep := 0.05
        majorStep := 0.10
        superStep := 1.00
    else if price < 100.0
        minorStep := 0.50
        majorStep := 1.00
        superStep := 5.00
    else if price < 1000.0
        minorStep := 1.0
        majorStep := 5.0
        superStep := 10.0
    else if price < 10000.0
        minorStep := 5.0
        majorStep := 10.0
        superStep := 100.0
    else if price < 100000.0
        minorStep := 50.0
        majorStep := 100.0
        superStep := 1000.0
    else
        minorStep := 100.0
        majorStep := 1000.0
        superStep := 5000.0
    [minorStep, majorStep, superStep]

f_near_grid(float price, float step, float tolerance) =>
    float nearest = math.round(price / step) * step
    math.abs(price - nearest) <= tolerance

f_round_quality(float price, float halfWidth, float manualStep, bool useManual) =>
    [minorStep, majorStep, superStep] = f_auto_round_steps(price, manualStep, useManual)
    float tolerance = math.max(halfWidth * 0.60, syminfo.mintick * 3.0)
    float quality = 0.0
    if f_near_grid(price, minorStep, tolerance)
        quality := 0.50
    if f_near_grid(price, majorStep, tolerance)
        quality := 0.80
    if f_near_grid(price, superStep, tolerance)
        quality := 1.00
    quality

f_zone_score(
     Zone zone,
     float touchWeight,
     float roleWeight,
     float volumeWeight,
     float gapWeight,
     float roundWeight,
     float manualStep,
     bool useManual
) =>
    float touchCombined = f_combined(zone.touchShort, zone.touchLong)
    float roleCombined = f_combined(zone.roleShort, zone.roleLong)
    float volumeCombined = f_combined(zone.volumeShort, zone.volumeLong)
    float gapCombined = f_combined(zone.gapShort, zone.gapLong)
    float quality = f_reaction_quality(zone)
    float touchComponent = f_clamp(touchCombined / 2.20, 0.0, 1.0) * (0.35 + 0.65 * quality)
    float roleComponent = f_clamp(roleCombined / 1.20, 0.0, 1.0)
    float volumeComponent = f_clamp(volumeCombined / 1.80, 0.0, 1.0)
    float gapComponent = f_clamp(gapCombined / 1.00, 0.0, 1.0)
    // 整数共振只放大已经存在价格证据的区间,避免画出纯心理位。
    float evidencePresence = f_clamp(f_total_evidence(zone) / 3.0, 0.0, 1.0)
    float roundComponent = f_round_quality(zone.center, zone.halfWidth, manualStep, useManual) * (0.20 + 0.80 * evidencePresence)
    float weightSum = math.max(
         touchWeight + roleWeight + volumeWeight + gapWeight + roundWeight,
         1.0
    )
    100.0 * (
         touchComponent * touchWeight +
         roleComponent * roleWeight +
         volumeComponent * volumeWeight +
         gapComponent * gapWeight +
         roundComponent * roundWeight
    ) / weightSum

f_find_merge_target(
     array<Zone> zones,
     float price,
     float eventWidth,
     bool gapActive,
     int gapDirection,
     float gapFillPrice
) =>
    int bestIndex = -1
    float bestDistance = 1e20
    int zoneCount = array.size(zones)
    if zoneCount > 0
        for index = 0 to zoneCount - 1
            Zone zone = array.get(zones, index)
            float distance = math.abs(zone.center - price)
            // V2 合并半径 0.45 -> 0.38,避免把不同反应点焊成一个宽区间。
            float mergeRadius = 0.38 * (zone.halfWidth + eventWidth)
            bool incompatibleGap = (
                 gapActive and
                 zone.gapActive and
                 (
                      gapDirection != zone.gapDirection or
                      math.abs(gapFillPrice - zone.gapFillPrice) > syminfo.mintick
                 )
            )
            if not incompatibleGap and distance <= mergeRadius and distance < bestDistance
                bestDistance := distance
                bestIndex := index
    bestIndex

f_remove_weakest(array<Zone> zones, float currentClose, float currentAtr) =>
    int weakestIndex = -1
    float weakestScore = 1e20
    int zoneCount = array.size(zones)
    if zoneCount > 0
        for index = 0 to zoneCount - 1
            Zone zone = array.get(zones, index)
            float evidence = f_total_evidence(zone)
            float quality = f_reaction_quality(zone)
            float distanceAtr = currentAtr > 0.0 ? math.abs(zone.center - currentClose) / currentAtr : 0.0
            float removalScore = evidence * quality * math.exp(-distanceAtr / DISTANCE_DECAY_ATR)
            if removalScore < weakestScore
                weakestScore := removalScore
                weakestIndex := index
    if weakestIndex >= 0
        array.remove(zones, weakestIndex)
    weakestIndex

f_group_spacing_ok(
     array<float> centers,
     array<int> selectedGroups,
     int candidateIndex,
     float minDistance
) =>
    bool spacingOk = true
    int selectedCount = array.size(selectedGroups)
    if selectedCount > 0
        for index = 0 to selectedCount - 1
            int selectedIndex = array.get(selectedGroups, index)
            float distance = math.abs(
                 array.get(centers, candidateIndex) - array.get(centers, selectedIndex)
            )
            if distance < minDistance
                spacingOk := false
                break
    spacingOk

f_add_candidate(
     array<Zone> zones,
     float price,
     float eventWidth,
     int eventTime,
     float touchEvidence,
     float volumeEvidence,
     float gapEvidence,
     bool gapActive,
     int gapDirection,
     float gapFillPrice,
     float currentClose,
     float currentAtr
) =>
    int targetIndex = f_find_merge_target(
         zones,
         price,
         eventWidth,
         gapActive,
         gapDirection,
         gapFillPrice
    )
    // gap 证据只参与评分,不进入 centerWeight,避免缺口回补后中心价仍被旧缺口权重锚定
    float eventWeight = math.max(touchEvidence * 0.85 + volumeEvidence * 0.15, 0.20)
    if targetIndex >= 0
        Zone zone = array.get(zones, targetIndex)
        float newWeight = zone.centerWeight + eventWeight
        zone.center := (zone.center * zone.centerWeight + price * eventWeight) / newWeight
        zone.halfWidth := (
             zone.halfWidth * zone.centerWeight + eventWidth * eventWeight
        ) / newWeight
        zone.centerWeight := newWeight
        zone.firstTime := math.min(zone.firstTime, eventTime)
        zone.lastEventTime := math.max(zone.lastEventTime, eventTime)
        f_add_evidence(zone, touchEvidence, 0.0, volumeEvidence, gapEvidence)
        if gapActive
            zone.gapActive := true
            zone.gapDirection := gapDirection
            zone.gapFillPrice := gapFillPrice
    else
        Zone newZone = Zone.new(
             price,
             eventWidth,
             eventWeight,
             touchEvidence,
             touchEvidence,
             0.0,
             0.0,
             volumeEvidence,
             volumeEvidence,
             gapEvidence,
             gapEvidence,
             eventTime,
             eventTime,
             int(na),
             int(na),
             int(na),
             0,
             false,
             0,
             int(na),
             0,
             int(na),
             gapActive,
             gapDirection,
             gapFillPrice,
             0,
             0,
             0
        )
        array.push(zones, newZone)
    if array.size(zones) > MAX_ENGINE_ZONES
        f_remove_weakest(zones, currentClose, currentAtr)
    targetIndex

f_merge_overlaps(array<Zone> zones) =>
    int firstIndex = 0
    while firstIndex < array.size(zones)
        int secondIndex = firstIndex + 1
        while secondIndex < array.size(zones)
            Zone firstZone = array.get(zones, firstIndex)
            Zone secondZone = array.get(zones, secondIndex)
            float distance = math.abs(firstZone.center - secondZone.center)
            float overlapThreshold = 0.38 * (firstZone.halfWidth + secondZone.halfWidth)
            bool incompatibleGap = (
                 firstZone.gapActive and
                 secondZone.gapActive and
                 (
                      firstZone.gapDirection != secondZone.gapDirection or
                      math.abs(firstZone.gapFillPrice - secondZone.gapFillPrice) > syminfo.mintick
                 )
            )
            if not incompatibleGap and distance <= overlapThreshold
                float combinedWeight = firstZone.centerWeight + secondZone.centerWeight
                firstZone.center := (
                     firstZone.center * firstZone.centerWeight +
                     secondZone.center * secondZone.centerWeight
                ) / combinedWeight
                firstZone.halfWidth := (
                     firstZone.halfWidth * firstZone.centerWeight +
                     secondZone.halfWidth * secondZone.centerWeight
                ) / combinedWeight
                firstZone.centerWeight := combinedWeight
                firstZone.touchShort += secondZone.touchShort
                firstZone.touchLong += secondZone.touchLong
                firstZone.roleShort += secondZone.roleShort
                firstZone.roleLong += secondZone.roleLong
                firstZone.volumeShort += secondZone.volumeShort
                firstZone.volumeLong += secondZone.volumeLong
                firstZone.gapShort += secondZone.gapShort
                firstZone.gapLong += secondZone.gapLong
                firstZone.firstTime := math.min(firstZone.firstTime, secondZone.firstTime)
                firstZone.touchCount += secondZone.touchCount
                firstZone.validReactionCount += secondZone.validReactionCount
                firstZone.failedReactionCount += secondZone.failedReactionCount
                if na(firstZone.lastTouchBar) or (
                     not na(secondZone.lastTouchBar) and
                     secondZone.lastTouchBar > firstZone.lastTouchBar
                )
                    firstZone.lastTouchBar := secondZone.lastTouchBar
                    firstZone.lastTouchDay := secondZone.lastTouchDay
                if na(firstZone.pendingTouchBar) or (
                     not na(secondZone.pendingTouchBar) and
                     secondZone.pendingTouchBar > firstZone.pendingTouchBar
                )
                    firstZone.pendingTouchBar := secondZone.pendingTouchBar
                    firstZone.pendingTouchSide := secondZone.pendingTouchSide
                    firstZone.pendingRole := secondZone.pendingRole
                if secondZone.lastEventTime > firstZone.lastEventTime
                    firstZone.lastEventTime := secondZone.lastEventTime
                if na(firstZone.lastRelationBar) or (
                     not na(secondZone.lastRelationBar) and
                     secondZone.lastRelationBar > firstZone.lastRelationBar
                )
                    firstZone.lastRelation := secondZone.lastRelation
                    firstZone.lastRelationBar := secondZone.lastRelationBar
                if na(firstZone.lastBreakBar) or (
                     not na(secondZone.lastBreakBar) and
                     secondZone.lastBreakBar > firstZone.lastBreakBar
                )
                    firstZone.lastBreakSide := secondZone.lastBreakSide
                    firstZone.lastBreakBar := secondZone.lastBreakBar
                if secondZone.gapActive
                    firstZone.gapActive := true
                    firstZone.gapDirection := secondZone.gapDirection
                    firstZone.gapFillPrice := secondZone.gapFillPrice
                array.remove(zones, secondIndex)
            else
                secondIndex += 1
        firstIndex += 1
    0

f_prune_stale(array<Zone> zones) =>
    int index = array.size(zones) - 1
    while index >= 0
        Zone zone = array.get(zones, index)
        if f_total_evidence(zone) < 0.10
            array.remove(zones, index)
        index -= 1
    0

f_update_zone(
     Zone zone,
     float currentAtr,
     float relativeVolume,
     int cooldownBars,
     int reactionBars,
     bool newTradingDay,
     float reactionHigh,
     float reactionLow,
     float reactionCloseHigh,
     float reactionCloseLow
) =>
    if newTradingDay
        f_decay_zone(zone)

    // 缺口完全回补后只移除缺口证据
    if zone.gapActive
        bool gapFilled = (
             zone.gapDirection == 1 and low <= zone.gapFillPrice
        ) or (
             zone.gapDirection == -1 and high >= zone.gapFillPrice
        )
        if gapFilled
            zone.gapActive := false
            zone.gapShort := 0.0
            zone.gapLong := 0.0

    float zoneTop = zone.center + zone.halfWidth
    float zoneBottom = zone.center - zone.halfWidth

    // 反应观察窗口结束后,按真实反向位移验证这次触及是否有效。
    // side = 1 表示价格从上方触及下方支撑区;side = -1 表示从下方触及上方阻力区。
    if not na(zone.pendingTouchBar) and bar_index - zone.pendingTouchBar >= reactionBars
        float favorableAtr = 0.0
        float adverseAtr = 0.0
        if zone.pendingTouchSide != 0 and currentAtr > 0.0 and not na(reactionHigh) and not na(reactionLow) and not na(reactionCloseHigh) and not na(reactionCloseLow)
            if zone.pendingTouchSide == 1
                favorableAtr := math.max((reactionCloseHigh - zone.center) / currentAtr, 0.0)
                adverseAtr := math.max((zone.center - reactionLow) / currentAtr, 0.0)
            else if zone.pendingTouchSide == -1
                favorableAtr := math.max((zone.center - reactionCloseLow) / currentAtr, 0.0)
                adverseAtr := math.max((reactionHigh - zone.center) / currentAtr, 0.0)
        if favorableAtr >= 0.35
            zone.validReactionCount += 1
            // 有效拒绝:反向位移越强、不利穿透越浅,证据越高。
            float rejectionEvidence = f_clamp(
                 0.30 + favorableAtr * 0.90 - adverseAtr * 0.45,
                 0.20,
                 1.60
            )
            f_add_evidence(zone, rejectionEvidence, 0.0, 0.0, 0.0)
            if zone.pendingRole
                float roleEvidence = f_clamp(0.40 + favorableAtr * 0.60, 0.0, 1.40)
                f_add_evidence(zone, 0.0, roleEvidence, 0.0, 0.0)
        else if adverseAtr >= 0.65
            // 无效穿越:价格继续朝不利方向推进,说明该区间没有防守。
            zone.failedReactionCount += 1
            zone.touchShort *= 0.88
            zone.touchLong *= 0.94
        zone.pendingTouchBar := int(na)
        zone.pendingTouchSide := 0
        zone.pendingRole := false

    bool intersects = high >= zoneBottom and low <= zoneTop
    bool cooldownReady = na(zone.lastTouchBar) or bar_index - zone.lastTouchBar >= cooldownBars
    if intersects and cooldownReady
        int approachSide = close[1] > zoneTop ? 1 : close[1] < zoneBottom ? -1 : 0
        int currentDay = time("D")
        bool distinctDay = na(zone.lastTouchDay) or currentDay != zone.lastTouchDay
        // V2 基础触及分略低于 V1,真正的强度交给随后的验证结果。
        float baseTouch = 0.40 + (distinctDay ? 0.15 : 0.0)
        float touchVolumeEvidence = f_clamp((relativeVolume - 1.0) * 0.50, 0.0, 1.00)
        f_add_evidence(zone, baseTouch, 0.0, touchVolumeEvidence, 0.0)
        zone.touchCount += 1
        zone.lastTouchBar := bar_index
        zone.lastTouchDay := currentDay
        zone.pendingTouchBar := bar_index
        zone.pendingTouchSide := approachSide
        zone.pendingRole := (
             approachSide != 0 and
             approachSide == zone.lastBreakSide and
             not na(zone.lastBreakBar) and
             bar_index - zone.lastBreakBar <= 60
        )

    // 收盘有效穿越会削弱旧区间;若以后回踩验证,可通过多空转换重新加分。
    float breakBuffer = zone.halfWidth * 0.30
    int relation = close > zoneTop + breakBuffer ? 1 : close < zoneBottom - breakBuffer ? -1 : 0
    if relation != 0
        if zone.lastRelation != 0 and relation != zone.lastRelation
            zone.touchShort *= 0.90
            zone.touchLong *= 0.96
            zone.roleShort *= 0.55
            zone.roleLong *= 0.65
            zone.lastBreakSide := relation
            zone.lastBreakBar := bar_index
        zone.lastRelation := relation
        zone.lastRelationBar := bar_index
    0

f_add_pivot_candidate(
     array<Zone> zones,
     float price,
     int eventTime,
     float eventAtr,
     float eventRvol,
     float touchBase,
     float widthFactor,
     float currentClose,
     float currentAtr,
     float volumeCap
) =>
    float eventWidth = f_event_half_width(price, eventAtr, widthFactor)
    float volumeEvidence = f_clamp((eventRvol - 1.0) * 0.60, 0.0, volumeCap)
    f_add_candidate(
         zones,
         price,
         eventWidth,
         eventTime,
         touchBase,
         volumeEvidence,
         0.0,
         false,
         0,
         float(na),
         currentClose,
         currentAtr
    )

f_swing_cluster_info(
     array<float> levels,
     array<int> bars,
     float price,
     int eventBar,
     float tolerance,
     int maxAgeBars
) =>
    int size = array.size(levels)
    int index = size - 1
    while index >= 0
        if eventBar - array.get(bars, index) > maxAgeBars
            array.remove(levels, index)
            array.remove(bars, index)
        index -= 1
    int matches = 0
    float sum = price
    int newSize = array.size(levels)
    if newSize > 0
        for matchIndex = 0 to newSize - 1
            if math.abs(price - array.get(levels, matchIndex)) <= tolerance
                matches += 1
                sum += array.get(levels, matchIndex)
    array.push(levels, price)
    array.push(bars, eventBar)
    float clusterCenter = matches > 0 ? sum / (matches + 1.0) : price
    [clusterCenter, matches + 1]

f_engine(
     string sensitivity,
     float widthFactor,
     int requestedZones,
     float minimumStrength,
     float maxDistanceAtr,
     float displayMergeAtr,
     float minSpacingAtr,
     bool useManualRound,
     float manualRoundStep,
     float touchWeight,
     float roleWeight,
     float volumeWeight,
     float gapWeight,
     float roundWeight,
     bool useLiquidityClusters,
     bool validatedOnly
) =>
    var array<Zone> zones = array.new<Zone>()
    var array<float> outputCenters = array.new<float>()
    var array<float> outputWidths = array.new<float>()
    var array<float> outputScores = array.new<float>()
    var array<int> outputTimes = array.new<int>()
    var array<float> swingHighs = array.new<float>()
    var array<int> swingHighBars = array.new<int>()
    var array<float> swingLows = array.new<float>()
    var array<int> swingLowBars = array.new<int>()
    var int processedBars = 0
    var int processedDays = 1
    var bool observedVolume = false
    var int lastProcessedBar = na

    float currentAtr = ta.atr(14)
    float volumeAverage = ta.sma(volume[1], 20)
    float relativeVolume = (
         not na(volume) and
         not na(volumeAverage) and
         volumeAverage > 0.0
    ) ? volume / volumeAverage : 1.0
    // 反应窗口使用固定长度内置函数,避免动态长度在 Pine 中的兼容问题。
    float reactionHigh = ta.highest(high, 4)
    float reactionLow = ta.lowest(low, 4)
    float reactionCloseHigh = ta.highest(close, 4)
    float reactionCloseLow = ta.lowest(close, 4)

    // 只处理已收盘的1小时K线,且同一根K线只处理一次。
    bool processBar = barstate.isconfirmed and (na(lastProcessedBar) or bar_index > lastProcessedBar)
    if processBar
        lastProcessedBar := bar_index
        processedBars += 1
        observedVolume := observedVolume or (not na(volume) and volume > 0.0)
        bool newTradingDay = ta.change(time("D")) > 0
        if newTradingDay
            processedDays += 1

        int pivotLegs = sensitivity == "保守" ? 5 : sensitivity == "密集" ? 3 : 4
        int cooldownBars = sensitivity == "保守" ? 7 : sensitivity == "密集" ? 3 : 5
        float gapAtrThreshold = sensitivity == "保守" ? 0.55 : sensitivity == "密集" ? 0.30 : 0.40
        float relativeVolumeThreshold = sensitivity == "保守" ? 1.70 : sensitivity == "密集" ? 1.20 : 1.40
        float wickThreshold = sensitivity == "保守" ? 0.50 : sensitivity == "密集" ? 0.30 : 0.40
        int clusterMaxAge = sensitivity == "保守" ? 140 : sensitivity == "密集" ? 200 : 170

        int existingCount = array.size(zones)
        if existingCount > 0
            for index = 0 to existingCount - 1
                Zone zone = array.get(zones, index)
                f_update_zone(
                     zone,
                     currentAtr,
                     relativeVolume,
                     cooldownBars,
                     4,
                     newTradingDay,
                     reactionHigh,
                     reactionLow,
                     reactionCloseHigh,
                     reactionCloseLow
                )

        bool candidateAdded = false

        // V2:短腿枢轴负责灵敏度,长腿枢轴负责主结构;多尺度共振自动获得更高证据。
        float pivotHighPrimary = ta.pivothigh(high, pivotLegs, pivotLegs)
        float pivotLowPrimary = ta.pivotlow(low, pivotLegs, pivotLegs)
        float pivotHighSecondary = ta.pivothigh(high, pivotLegs + 2, pivotLegs + 2)
        float pivotLowSecondary = ta.pivotlow(low, pivotLegs + 2, pivotLegs + 2)

        int pivotEventTime = time[pivotLegs]
        int pivotEventBar = bar_index - pivotLegs
        int pivotSecondaryTime = time[pivotLegs + 2]

        if not na(pivotHighPrimary) and not na(currentAtr[pivotLegs]) and currentAtr[pivotLegs] > 0.0
            float pivotAtr = currentAtr[pivotLegs]
            float pivotRvol = relativeVolume[pivotLegs]
            f_add_pivot_candidate(
                 zones,
                 pivotHighPrimary,
                 pivotEventTime,
                 pivotAtr,
                 pivotRvol,
                 0.75,
                 widthFactor,
                 close,
                 currentAtr,
                 1.20
            )
            candidateAdded := true
            if useLiquidityClusters
                float clusterTolerance = math.max(currentAtr * 0.30, pivotHighPrimary * 0.0012)
                [clusterCenter, clusterCount] = f_swing_cluster_info(
                     swingHighs,
                     swingHighBars,
                     pivotHighPrimary,
                     pivotEventBar,
                     clusterTolerance,
                     clusterMaxAge
                )
                if clusterCount >= 2
                    f_add_candidate(
                         zones,
                         clusterCenter,
                         f_event_half_width(clusterCenter, pivotAtr, widthFactor),
                         pivotEventTime,
                         0.45 * math.min(clusterCount, 4),
                         0.0,
                         0.0,
                         false,
                         0,
                         float(na),
                         close,
                         currentAtr
                    )
                    candidateAdded := true

        if not na(pivotLowPrimary) and not na(currentAtr[pivotLegs]) and currentAtr[pivotLegs] > 0.0
            float pivotAtr = currentAtr[pivotLegs]
            float pivotRvol = relativeVolume[pivotLegs]
            f_add_pivot_candidate(
                 zones,
                 pivotLowPrimary,
                 pivotEventTime,
                 pivotAtr,
                 pivotRvol,
                 0.75,
                 widthFactor,
                 close,
                 currentAtr,
                 1.20
            )
            candidateAdded := true
            if useLiquidityClusters
                float clusterTolerance = math.max(currentAtr * 0.30, pivotLowPrimary * 0.0012)
                [clusterCenter, clusterCount] = f_swing_cluster_info(
                     swingLows,
                     swingLowBars,
                     pivotLowPrimary,
                     pivotEventBar,
                     clusterTolerance,
                     clusterMaxAge
                )
                if clusterCount >= 2
                    f_add_candidate(
                         zones,
                         clusterCenter,
                         f_event_half_width(clusterCenter, pivotAtr, widthFactor),
                         pivotEventTime,
                         0.45 * math.min(clusterCount, 4),
                         0.0,
                         0.0,
                         false,
                         0,
                         float(na),
                         close,
                         currentAtr
                    )
                    candidateAdded := true

        if not na(pivotHighSecondary) and not na(currentAtr[pivotLegs + 2]) and currentAtr[pivotLegs + 2] > 0.0
            float pivotAtr = currentAtr[pivotLegs + 2]
            float pivotRvol = relativeVolume[pivotLegs + 2]
            f_add_pivot_candidate(
                 zones,
                 pivotHighSecondary,
                 pivotSecondaryTime,
                 pivotAtr,
                 pivotRvol,
                 0.95,
                 widthFactor,
                 close,
                 currentAtr,
                 1.00
            )
            candidateAdded := true

        if not na(pivotLowSecondary) and not na(currentAtr[pivotLegs + 2]) and currentAtr[pivotLegs + 2] > 0.0
            float pivotAtr = currentAtr[pivotLegs + 2]
            float pivotRvol = relativeVolume[pivotLegs + 2]
            f_add_pivot_candidate(
                 zones,
                 pivotLowSecondary,
                 pivotSecondaryTime,
                 pivotAtr,
                 pivotRvol,
                 0.95,
                 widthFactor,
                 close,
                 currentAtr,
                 1.00
            )
            candidateAdded := true

        // 加密货币全天交易没有传统“隔夜缺口”,改为识别相邻1小时K线之间
        // 无重叠的显著跳空(常由杠杆清算瀑布、插针或突发消息造成)。
        if not na(close[1]) and not na(currentAtr[1]) and currentAtr[1] > 0.0
            float previousClose = close[1]
            float previousHigh = high[1]
            float previousLow = low[1]
            float gapAtr = currentAtr[1]
            float upGapSize = open - previousHigh
            float downGapSize = previousLow - open
            float minimumGap = math.max(gapAtr * gapAtrThreshold, previousClose * 0.0030)
            int gapDirection = 0
            float lowerEdge = na
            float upperEdge = na
            float gapFillPrice = na
            bool filledOnOpeningBar = false
            if upGapSize >= minimumGap
                gapDirection := 1
                lowerEdge := previousHigh
                upperEdge := open
                gapFillPrice := previousHigh
                filledOnOpeningBar := low <= previousHigh
            else if downGapSize >= minimumGap
                gapDirection := -1
                lowerEdge := open
                upperEdge := previousLow
                gapFillPrice := previousLow
                filledOnOpeningBar := high >= previousLow
            if gapDirection != 0
                float openingGapEvidence = filledOnOpeningBar ? 0.0 : 0.90
                float lowerWidth = f_event_half_width(lowerEdge, gapAtr, widthFactor)
                float upperWidth = f_event_half_width(upperEdge, gapAtr, widthFactor)
                f_add_candidate(
                     zones,
                     lowerEdge,
                     lowerWidth,
                     time,
                     0.20,
                     0.0,
                     openingGapEvidence,
                     not filledOnOpeningBar,
                     gapDirection,
                     gapFillPrice,
                     close,
                     gapAtr
                )
                f_add_candidate(
                     zones,
                     upperEdge,
                     upperWidth,
                     time,
                     0.20,
                     0.0,
                     openingGapEvidence,
                     not filledOnOpeningBar,
                     gapDirection,
                     gapFillPrice,
                     close,
                     gapAtr
                )
                candidateAdded := true

        // 放量拒绝K线补充没有形成标准枢轴的结构边缘
        float candleRange = high - low
        if (
             candleRange > 0.0 and
             not na(currentAtr) and
             currentAtr > 0.0 and
             relativeVolume >= relativeVolumeThreshold
        )
            float lowerWickRatio = (math.min(open, close) - low) / candleRange
            float upperWickRatio = (high - math.max(open, close)) / candleRange
            float volumeEvidence = f_clamp((relativeVolume - 1.0) * 0.90, 0.0, 1.50)
            if lowerWickRatio >= wickThreshold
                float eventWidth = f_event_half_width(low, currentAtr, widthFactor)
                int lowerTarget = f_find_merge_target(zones, low, eventWidth, false, 0, float(na))
                bool lowerAlreadyTouched = false
                if lowerTarget >= 0
                    lowerAlreadyTouched := array.get(zones, lowerTarget).lastTouchBar == bar_index
                if not lowerAlreadyTouched
                    f_add_candidate(
                         zones,
                         low,
                         eventWidth,
                         time,
                         0.60,
                         volumeEvidence,
                         0.0,
                         false,
                         0,
                         float(na),
                         close,
                         currentAtr
                    )
                    candidateAdded := true
            if upperWickRatio >= wickThreshold
                float eventWidth = f_event_half_width(high, currentAtr, widthFactor)
                int upperTarget = f_find_merge_target(zones, high, eventWidth, false, 0, float(na))
                bool upperAlreadyTouched = false
                if upperTarget >= 0
                    upperAlreadyTouched := array.get(zones, upperTarget).lastTouchBar == bar_index
                if not upperAlreadyTouched
                    f_add_candidate(
                         zones,
                         high,
                         eventWidth,
                         time,
                         0.60,
                         volumeEvidence,
                         0.0,
                         false,
                         0,
                         float(na),
                         close,
                         currentAtr
                    )
                    candidateAdded := true

        if candidateAdded
            f_merge_overlaps(zones)
        if processedBars % 20 == 0
            f_prune_stale(zones)

    // 集合只在请求上下文的最后一根K线上返回,避免为每根历史K线复制数组
    ZoneSnapshot result = na
    if barstate.islast
        array.clear(outputCenters)
        array.clear(outputWidths)
        array.clear(outputScores)
        array.clear(outputTimes)

        // 显示层使用已确认的 close/ATR,避免未收盘 1H K 线导致分数与选区随 tick 变化。
        float displayClose = barstate.isconfirmed ? close : close[1]
        float displayAtr = barstate.isconfirmed ? currentAtr : currentAtr[1]

        array<float> eligibleScores = array.new<float>()
        array<int> eligibleIndices = array.new<int>()
        int zoneCount = array.size(zones)
        if zoneCount > 0 and not na(displayAtr) and displayAtr > 0.0
            for index = 0 to zoneCount - 1
                Zone zone = array.get(zones, index)
                float structureScore = f_zone_score(
                     zone,
                     touchWeight,
                     roleWeight,
                     volumeWeight,
                     gapWeight,
                     roundWeight,
                     manualRoundStep,
                     useManualRound
                )
                float distanceAtr = math.abs(zone.center - displayClose) / displayAtr
                float distanceScore = 100.0 * math.exp(-distanceAtr / DISTANCE_DECAY_ATR)
                float tradabilityScore = (
                     structureScore * STRUCTURE_RANK_WEIGHT +
                     distanceScore * DISTANCE_RANK_WEIGHT
                )
                bool withinDistance = distanceAtr <= maxDistanceAtr
                bool enoughStructure = structureScore >= minimumStrength * STRUCTURE_FLOOR_RATIO
                bool hasValidation = not validatedOnly or zone.validReactionCount > 0

                if tradabilityScore >= minimumStrength and enoughStructure and withinDistance and hasValidation
                    array.push(eligibleScores, tradabilityScore)
                    array.push(eligibleIndices, index)

        // 先以最高分微区间为锚点,再按ATR距离聚合邻近价位
        int eligibleCount = array.size(eligibleScores)
        array<float> groupAnchors = array.new<float>()
        array<float> groupCenters = array.new<float>()
        array<float> groupWidths = array.new<float>()
        array<float> groupScores = array.new<float>()
        array<float> groupWeights = array.new<float>()
        array<int> groupTimes = array.new<int>()
        if eligibleCount > 0
            array<int> sortedCandidatePositions = array.sort_indices(
                 eligibleScores,
                 order.descending
            )
            for orderPosition = 0 to eligibleCount - 1
                int candidatePosition = array.get(sortedCandidatePositions, orderPosition)
                int zoneIndex = array.get(eligibleIndices, candidatePosition)
                Zone candidateZone = array.get(zones, zoneIndex)
                float candidateScore = array.get(eligibleScores, candidatePosition)
                float mergeDistance = displayAtr * displayMergeAtr
                int targetGroup = -1
                float nearestDistance = 1e20
                int currentGroupCount = array.size(groupCenters)
                bool candidateIsUpper = candidateZone.center >= displayClose
                if currentGroupCount > 0
                    for groupIndex = 0 to currentGroupCount - 1
                        float groupAnchor = array.get(groupAnchors, groupIndex)
                        bool groupIsUpper = groupAnchor >= displayClose
                        float distance = math.abs(
                             candidateZone.center - groupAnchor
                        )
                        // 不跨现价合并,避免上下方区间被压成一个无效中点
                        if (
                             candidateIsUpper == groupIsUpper and
                             distance <= mergeDistance and
                             distance < nearestDistance
                        )
                            targetGroup := groupIndex
                            nearestDistance := distance

                float candidateWeight = math.max(candidateScore, 1.0)
                if targetGroup >= 0
                    float oldCenter = array.get(groupCenters, targetGroup)
                    float oldWeight = array.get(groupWeights, targetGroup)
                    float newWeight = oldWeight + candidateWeight
                    float newCenter = (
                         oldCenter * oldWeight +
                         candidateZone.center * candidateWeight
                    ) / newWeight
                    float oldScore = array.get(groupScores, targetGroup)
                    float combinedScore = f_clamp(
                         math.max(oldScore, candidateScore) +
                         math.min(oldScore, candidateScore) * 0.15,
                         0.0,
                         100.0
                    )
                    array.set(groupCenters, targetGroup, newCenter)
                    array.set(
                         groupWidths,
                         targetGroup,
                         (
                              array.get(groupWidths, targetGroup) * oldWeight +
                              candidateZone.halfWidth * candidateWeight
                         ) / newWeight
                    )
                    array.set(groupScores, targetGroup, combinedScore)
                    array.set(groupWeights, targetGroup, newWeight)
                    array.set(
                         groupTimes,
                         targetGroup,
                         math.min(array.get(groupTimes, targetGroup), candidateZone.firstTime)
                    )
                else
                    array.push(groupAnchors, candidateZone.center)
                    array.push(groupCenters, candidateZone.center)
                    array.push(groupWidths, candidateZone.halfWidth)
                    array.push(groupScores, candidateScore)
                    array.push(groupWeights, candidateWeight)
                    array.push(groupTimes, candidateZone.firstTime)

        // 上下方分别保留一半名额,不足时再由另一侧高分区间补齐
        int groupCount = array.size(groupScores)
        if groupCount > 0
            float spacingDistance = displayAtr * minSpacingAtr
            array<int> sortedGroupIndices = array.sort_indices(groupScores, order.descending)
            array<int> selectedGroups = array.new<int>()
            int selectedLimit = math.min(requestedZones, groupCount)
            int upperQuota = int(math.ceil(selectedLimit / 2.0))
            int lowerQuota = selectedLimit - upperQuota
            int upperCount = 0
            int lowerCount = 0

            for orderPosition = 0 to groupCount - 1
                int groupIndex = array.get(sortedGroupIndices, orderPosition)
                bool isUpper = array.get(groupCenters, groupIndex) >= displayClose
                bool spacingOk = f_group_spacing_ok(groupCenters, selectedGroups, groupIndex, spacingDistance)
                if (
                     isUpper and
                     upperCount < upperQuota and
                     array.size(selectedGroups) < selectedLimit and
                     spacingOk
                )
                    array.push(selectedGroups, groupIndex)
                    upperCount += 1
                else if (
                     not isUpper and
                     lowerCount < lowerQuota and
                     array.size(selectedGroups) < selectedLimit and
                     spacingOk
                )
                    array.push(selectedGroups, groupIndex)
                    lowerCount += 1

            // 某一侧候选不足或被间距过滤时,继续按综合分数填满剩余名额
            for orderPosition = 0 to groupCount - 1
                int groupIndex = array.get(sortedGroupIndices, orderPosition)
                bool spacingOk = f_group_spacing_ok(groupCenters, selectedGroups, groupIndex, spacingDistance)
                if (
                     array.size(selectedGroups) < selectedLimit and
                     not array.includes(selectedGroups, groupIndex) and
                     spacingOk
                )
                    array.push(selectedGroups, groupIndex)

            int selectedCount = array.size(selectedGroups)
            if selectedCount > 0
                for selectionPosition = 0 to selectedCount - 1
                    int groupIndex = array.get(selectedGroups, selectionPosition)
                    float groupCenter = array.get(groupCenters, groupIndex)
                    // V2 显示半宽更窄,让中心价更贴近真实反应点。
                    float displayPadding = displayAtr * 0.02
                    float displayHalfWidth = math.min(
                         array.get(groupWidths, groupIndex) + displayPadding,
                         displayAtr * 0.30
                    )
                    array.push(outputCenters, groupCenter)
                    array.push(outputWidths, displayHalfWidth)
                    array.push(outputScores, array.get(groupScores, groupIndex))
                    array.push(outputTimes, array.get(groupTimes, groupIndex))

        result := ZoneSnapshot.new(
             outputCenters,
             outputWidths,
             outputScores,
             outputTimes,
             displayAtr,
             displayClose,
             close,
             processedDays,
             observedVolume
        )
    result

// 将输入映射为稳定的内部参数
// V2 标准宽度 0.28 ATR,比 V1 的 0.30 略窄,减少多个关键位互相覆盖。
float widthFactor = widthInput == "窄" ? 0.22 : widthInput == "宽" ? 0.38 : 0.28
bool useManualRound = roundModeInput == "手动"
bool supportedTimeframe = (
     timeframe.isintraday and
     timeframe.in_seconds() >= 60 and
     timeframe.in_seconds() <= 3600
)

// 加密货币全天交易,ticker.new 的会话参数只接受 session.regular / session.extended。
// 使用 session.extended 请求全部数据,避免任何按交易时段的过滤。
string cryptoTicker = ticker.new(syminfo.prefix, syminfo.ticker, session.extended)
ZoneSnapshot snapshot = request.security(
     cryptoTicker,
     ANALYSIS_TIMEFRAME,
     f_engine(
          sensitivityInput,
          widthFactor,
          maxZonesInput,
          minStrengthInput,
          maxDistanceAtrInput,
          displayMergeAtrInput,
          minSpacingAtrInput,
          useManualRound,
          manualRoundStepInput,
          touchWeightInput,
          roleWeightInput,
          volumeWeightInput,
          gapWeightInput,
          roundWeightInput,
          liquidityClustersInput,
          validatedOnlyInput
     ),
     gaps = barmerge.gaps_off,
     lookahead = barmerge.lookahead_off,
     ignore_invalid_symbol = true,
     calc_bars_count = CALC_BARS_COUNT
)

// 绘图对象只创建一次,之后使用设置函数复用
var array<box> heatBoxes = array.new<box>()
var array<line> centerLines = array.new<line>()
var array<label> scoreLabels = array.new<label>()
var table statusTable = table.new(position.top_right, 1, 1)

if barstate.isfirst
    for index = 0 to MAX_DRAW_ZONES - 1
        box heatBox = box.new(
             left = na,
             top = na,
             right = na,
             bottom = na,
             xloc = xloc.bar_time,
             extend = extend.right,
             border_color = na,
             bgcolor = na
        )
        line centerLine = line.new(
             x1 = na,
             y1 = na,
             x2 = na,
             y2 = na,
             xloc = xloc.bar_time,
             extend = extend.right,
             color = na,
             style = line.style_dotted
        )
        label scoreLabel = label.new(
             x = na,
             y = na,
             text = "",
             xloc = xloc.bar_time,
             style = label.style_label_left,
             color = na,
             textcolor = na,
             size = labelSizeInput == "小" ? size.tiny : labelSizeInput == "大" ? size.normal : size.small
        )
        array.push(heatBoxes, heatBox)
        array.push(centerLines, centerLine)
        array.push(scoreLabels, scoreLabel)

f_hide_drawing(int index) =>
    box heatBox = array.get(heatBoxes, index)
    line centerLine = array.get(centerLines, index)
    label scoreLabel = array.get(scoreLabels, index)
    box.set_top(heatBox, na)
    box.set_bottom(heatBox, na)
    box.set_bgcolor(heatBox, na)
    box.set_border_color(heatBox, na)
    line.set_y1(centerLine, na)
    line.set_y2(centerLine, na)
    line.set_color(centerLine, na)
    label.set_y(scoreLabel, na)
    label.set_text(scoreLabel, "")
    label.set_color(scoreLabel, na)
    label.set_textcolor(scoreLabel, na)

if barstate.islast
    bool snapshotReady = not na(snapshot)
    bool volumeReady = false
    bool historyReady = false
    if snapshotReady
        volumeReady := snapshot.volumeAvailable
        historyReady := snapshot.processedDays >= MIN_HISTORY_DAYS
    bool canDraw = supportedTimeframe and snapshotReady and volumeReady and historyReady

    string statusText = ""
    color statusColor = color.new(color.black, 100)
    if not supportedTimeframe
        statusText := "仅支持1分钟至1小时图表"
        statusColor := color.new(color.orange, 15)
    else if not snapshotReady
        statusText := "等待1小时数据"
        statusColor := color.new(color.orange, 15)
    else if not volumeReady
        statusText := "当前标的缺少成交量数据"
        statusColor := color.new(color.orange, 15)
    else if not historyReady
        statusText := "历史数据不足,需要约75天"
        statusColor := color.new(color.orange, 15)
    table.cell(
         statusTable,
         0,
         0,
         statusText,
         text_color = color.white,
         bgcolor = statusColor,
         text_size = size.small
    )

    int visibleCount = canDraw ? array.size(snapshot.centers) : 0
    int rightTime = chart.right_visible_bar_time
    for index = 0 to MAX_DRAW_ZONES - 1
        if index < visibleCount
            float center = array.get(snapshot.centers, index)
            float halfWidth = array.get(snapshot.halfWidths, index)
            float strength = array.get(snapshot.scores, index)
            int firstTime = array.get(snapshot.firstTimes, index)
            // 以当前显示门槛为起点拉开明暗,高分区间获得同步视觉强调
            float visualRange = math.max(100.0 - minStrengthInput, 1.0)
            float visualIntensity = f_clamp(
                 (strength - minStrengthInput) / visualRange,
                 0.0,
                 1.0
            )
            int fillTransparency = int(math.round(78.0 - visualIntensity * 34.0))
            int borderTransparency = int(math.round(64.0 - visualIntensity * 46.0))
            int lineTransparency = int(math.round(58.0 - visualIntensity * 48.0))
            int labelTransparency = int(math.round(38.0 - visualIntensity * 30.0))
            color fillColor = color.new(heatColorInput, fillTransparency)
            color borderColor = color.new(heatColorInput, borderTransparency)
            bool priceInside = snapshot.liveClose >= center - halfWidth and snapshot.liveClose <= center + halfWidth
            bool highScoreEmphasis = visualIntensity >= 0.60

            box heatBox = array.get(heatBoxes, index)
            box.set_lefttop(heatBox, firstTime, center + halfWidth)
            box.set_rightbottom(heatBox, time, center - halfWidth)
            box.set_bgcolor(heatBox, fillColor)
            box.set_border_color(heatBox, borderColor)
            box.set_border_width(heatBox, priceInside or highScoreEmphasis ? 2 : 1)

            line centerLine = array.get(centerLines, index)
            line.set_xy1(centerLine, firstTime, center)
            line.set_xy2(centerLine, time, center)
            line.set_color(
                 centerLine,
                 showCenterInput ? color.new(heatColorInput, lineTransparency) : na
            )
            line.set_width(centerLine, priceInside or highScoreEmphasis ? 2 : 1)

            label scoreLabel = array.get(scoreLabels, index)
            label.set_x(scoreLabel, rightTime)
            label.set_y(scoreLabel, center)
            label.set_text(
                 scoreLabel,
                 showLabelsInput
                      ? str.tostring(center, format.mintick) + " | " + str.tostring(math.round(strength))
                      : ""
            )
            label.set_color(
                 scoreLabel,
                 showLabelsInput ? color.new(heatColorInput, labelTransparency) : na
            )
            label.set_textcolor(scoreLabel, showLabelsInput ? color.white : na)
        else
            f_hide_drawing(index)

Overview

Using closed one-hour price data, it evaluates multi-scale pivots, liquidity clusters, relative volume, and round-number resonances to score structural key levels. Validated price reactions and role reversals dynamically increase or decrease zone strength. Traders can adjust sensitivity presets, maximum zone counts, display distances, and custom scoring weights. It renders heatmap bands with optional centerlines and labels.

Details

Published by

NeuNue on GitHub

Type

Indicator, Pine Script v6

Draws

On the price chart

Category

Support & resistance, SMC / ICT / price action, Volume

Markets

Crypto

Timeframes

M1-M5, M15-H1

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.

Settings

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

SettingTypeDefaultVariable
灵敏度string标准sensitivityInput
最大区间数int8maxZonesInput
最低强度float40.0minStrengthInput
邻近区间合并距离(ATR)float0.70displayMergeAtrInput
最小显示间距(ATR)float2.25minSpacingAtrInput
区间宽度string标准widthInput
流动性集群识别booltrueliquidityClustersInput
仅显示已验证区间booltruevalidatedOnlyInput
整数步长string自动roundModeInput
手动基础步长float5.0manualRoundStepInput
显示中心线booltrueshowCenterInput
显示价位与强度booltrueshowLabelsInput
Show all 20 settings
SettingTypeDefaultVariable
标签字号string中labelSizeInput
最大显示距离(ATR)float10.0maxDistanceAtrInput
热力颜色colorcolor.rgb(91, 76, 255)heatColorInput
触及与拒绝权重float45.0touchWeightInput
多空转换权重float25.0roleWeightInput
相对成交量权重float20.0volumeWeightInput
缺口权重float5.0gapWeightInput
整数共振权重float5.0roundWeightInput
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

Moon Dev GEX Sweep & CVD

by moondevonyt
92 stars

The indicator detects liquidity sweeps across key price levels and calculates a cumulative volume delta proxy. It uses pivot high and low lookbacks to identify swing points, marking sweeps when price breaks beyond a level and quickly closes back inside. Featur

Indicator v5 Support & resistanceSMC / ICT / price action
Updated 8 weeks ago · Custom licence GitHub repository of Moon Dev GEX Sweep & CVD

Calculates higher-timeframe volume profiles to identify Value Area High (VAH), Value Area Low (VAL), and Point of Control (POC) levels directly on the price chart. It flags potential reversal setups and Change in State of Delivery (CISD) patterns relative to v

Indicator v6 VolumeSupport & resistance
Updated 2 months ago · None stated GitHub repository of CandelaCharts - Value Area Reversals

Copilot

by milocaetano
48 stars

Copilot is an order flow range-reversal indicator designed to overlay trading setups directly on the price chart. It evaluates four criteria: market context (sideways vs trending regime based on ATR height), structure (double top or double bottom retests), abs

Indicator v5 SMC / ICT / price actionSupport & resistance
Updated yesterday · MIT GitHub repository of Copilot

Supply and Demand Visible Range identifies supply, demand, and equilibrium zones across the visible chart range. It accumulates high, low, and volume data starting from the leftmost visible bar, optionally requesting lower timeframe intrabar data for finer res

Indicator v5 Support & resistanceVolume Repaints
Updated 2 months ago · AGPL-3.0 GitHub repository of Supply and Demand Visible Range [LuxAlgo]
13 stars

This script identifies and visualizes bullish and bearish order blocks on the price chart. It detects low-volatility consolidation ranges followed by breakout moves accompanied by high trading volume. Users can select between Simple, Relative, or Weighted volu

Indicator v6 SMC / ICT / price actionSupport & resistance
Updated 6 months ago · MIT GitHub repository of Advanced Order Blocks with Volume

This indicator estimates crypto liquidation levels by tracking Open Interest changes from Binance USDT perpetual contracts. It calculates Open Interest delta spikes relative to a simple moving average and projects estimated liquidation price levels across mult

Indicator v5 Support & resistanceSMC / ICT / price action

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