//@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)
Show all 2,204 lines