在股票行情类App中,除了常见的MACD、KDJ,CCI(Commodity Channel Index,商品通道指数)也是一个高频出现的技术指标。它通过测量当前价格偏离统计平均值的程度,来判断市场是否处于超买或超卖状态。相比直接依赖第三方图表库,用Core Graphics手绘一套CCI副图,不仅体积更小,还能完全掌控视觉细节。本文将从公式计算、坐标映射、区域标记到反转信号检测,完整走一遍实现流程。

一、CCI指标的计算原理与Swift实现
CCI由Donald Lambert提出,核心思想是:先计算每个交易周期的典型价格TP,再求TP与其简单移动平均SMA的偏差,最后用平均绝对偏差MD对偏差进行归一化。标准周期为20日,公式为:TP = (最高价 + 最低价 + 收盘价) / 3;CCI = (TP - SMA(TP, N)) / (0.015 × MD),其中MD是过去N个周期内TP与SMA差值的绝对值的平均。
计算时有两个容易踩坑的地方。一是前N-1个周期数据不足,CCI无法定义,绘制时应跳过这些点;二是MD不是TP的标准差,而是绝对偏差的均值,两者结果差异明显,混用会导致曲线形态完全不同。下面是完整的Swift实现:
struct Candle {
let high: Double
let low: Double
let close: Double
}
func calculateCCI(candles: [Candle], period: Int = 20) -> [Double?] {
guard candles.count >= period else {
return Array(repeating: nil, count: candles.count)
}
// 先算典型价格序列
let tps = candles.map { ($0.high + $0.low + $0.close) / 3.0 }
var result: [Double?] = Array(repeating: nil, count: candles.count)
var smaSum = 0.0
// 滑动窗口累计,避免每个点重复求和
for i in 0..<candles.count {
smaSum += tps[i]
if i >= period {
smaSum -= tps[i - period]
}
if i >= period - 1 {
let sma = smaSum / Double(period)
// 平均绝对偏差
var devSum = 0.0
for j in (i - period + 1)...i {
devSum += abs(tps[j] - sma)
}
let md = devSum / Double(period)
result[i] = md == 0 ? 0 : (tps[i] - sma) / (0.015 * md)
}
}
return result
}这段代码用滑动窗口维护SMA的和,把整体复杂度从O(n×period)降到近似O(n),对于分时数据动辄几千根K线的场景,性能提升非常可观。而MD部分因为依赖当前窗口的均值,无法完全增量计算,如果数据量特别大,可以考虑用GCD并发分段处理。
二、用Core Graphics绘制CCI副图曲线
拿到CCI数值序列后,下一步是把它画到屏幕上。CCI的取值范围理论上没有边界,常见行情区间在-300到+300之间,绘图时通常将纵轴显示范围裁剪到固定区间,比如-250到+250,超出部分直接画到边界即可,这样能让主要波动区域占据更多可视空间。
坐标映射是绘图的核心。横轴上,每个周期对应一个固定宽度candleWidth,纵轴上需要做一个线性变换,把指标值映射到视图的像素坐标。注意UIKit的y轴向下为正,所以CCI值越大,对应的y坐标越小,映射时要反转一次。绘制曲线推荐使用CGMutablePath配合CGContext.addPath,比逐条画线段的效率高很多:
class CCIRenderer {
let overbought: CGFloat = 100
let oversold: CGFloat = -100
func draw(context: CGContext, rect: CGRect,
values: [Double?], startIndex: Int) {
let candleWidth = rect.width / CGFloat(values.count)
let rangeLow = -250.0, rangeHigh = 250.0
func yFor(_ v: Double) -> CGFloat {
let clamped = max(rangeLow, min(rangeHigh, v))
let ratio = (clamped - rangeLow) / (rangeHigh - rangeLow)
// y轴翻转:值越大越靠上
return rect.maxY - CGFloat(ratio) * rect.height
}
// 绘制零轴与±100参考线
context.setStrokeColor(UIColor.lightGray.withAlphaComponent(0.5).cgColor)
for level in [overbought, 0, oversold] {
let y = yFor(Double(level))
let path = CGMutablePath()
path.move(to: CGPoint(x: rect.minX, y: y))
path.addLine(to: CGPoint(x: rect.maxX, y: y))
context.addPath(path)
}
context.strokePath()
// 绘制CCI折线
let line = CGMutablePath()
var started = false
for (i, v) in values.enumerated() {
guard let v = v else { continue }
let x = rect.minX + (CGFloat(i) + 0.5) * candleWidth
let point = CGPoint(x: x, y: yFor(v))
if started {
line.addLine(to: point)
} else {
line.move(to: point)
started = true
}
}
context.setStrokeColor(UIColor.systemYellow.cgColor)
context.setLineWidth(1.5)
context.addPath(line)
context.strokePath()
}
}实际项目中建议把绘制逻辑放到CGContext的透明图层(transparencyLayer)里,或者直接在draw(_ rect: CGRect)里先执行UIGraphicsPushContext相关的状态保存。另外,如果视图支持捏合缩放,重绘频率会很高,此时应该用CADisplayLink控制刷新节奏,并对可见范围外的数据做裁剪,只绘制屏幕内的点。
三、超买超卖区域着色与趋势反转信号检测
CCI的经典用法是:指标上穿+100视为进入超买强势区,可考虑持股;从+100上方回落跌破+100,往往是短期见顶信号。对称地,跌破-100进入超卖区,从-100下方反弹上穿-100,则是潜在的抄底时机。视觉上,可以在+100到上边界之间填充半透明红色表示超买,-100到下边界之间填充半透明绿色表示超卖,用户一眼就能识别风险区域。
填充区域的实现思路是构造闭合路径:分别取CCI曲线上所有值大于100的线段,把线段与y(+100)这条水平线围成的区域填充即可。更简单的做法是直接填充两个矩形背景带,再叠加曲线,效果同样直观且代码量极少:
// 绘制超买超卖背景带
let overboughtRect = CGRect(x: rect.minX,
y: rect.minY,
width: rect.width,
height: yFor(100) - rect.minY)
context.setFillColor(UIColor.red.withAlphaComponent(0.08).cgColor)
context.fill(overboughtRect)
let oversoldRect = CGRect(x: rect.minX,
y: yFor(-100),
width: rect.width,
height: rect.maxY - yFor(-100))
context.setFillColor(UIColor.green.withAlphaComponent(0.08).cgColor)
context.fill(oversoldRect)
// 趋势反转信号检测:CCI上穿或下穿关键阈值
func detectCrossSignals(values: [Double?],
upper: Double = 100,
lower: Double = -100) -> [(index: Int, type: String)] {
var signals = [(Int, String)]()
for i in 1..<values.count {
guard let prev = values[i - 1], let cur = values[i] else { continue }
if prev <= upper && cur > upper {
signals.append((i, "enterOverbought"))
} else if prev >= upper && cur < upper {
signals.append((i, "exitOverbought"))
} else if prev >= lower && cur < lower {
signals.append((i, "enterOversold"))
} else if prev <= lower && cur > lower {
signals.append((i, "exitOversold"))
}
}
return signals
}检测到信号点后,可以在对应K线下方画一个小圆点或箭头做提示,也可以结合触控手势,让用户点按信号点时弹出气泡显示具体数值和时间。需要注意,CCI在震荡行情中会频繁穿越阈值产生假信号,工程上一般要求配合均线方向或成交量做二次过滤,比如仅当CCI上穿+100且收盘价高于20日均线时才标记为有效反转信号。
四、工程整合建议
把CCI副图集成到整体行情页时,主图(K线+均线)和副图通常共用一个横向滚动容器,用一个UIScrollView承载整个绘制视图,副图高度一般占整体的三分之一左右。两幅图必须共享同一套横轴映射,保证缩放和平移时K线与CCI严格对齐,否则用户交叉观察时会产生错位错觉。
数据层面,建议将指标计算与视图渲染解耦:网络层拉取K线后,先在Model层批量算好CCI数组,视图只负责读取数组绘制。这样无论是切换周期(日K、周K)还是叠加其他指标,都不需要改动渲染代码。对于实时行情推送的场景,只需增量计算最后一根K线对应的CCI值并刷新末尾几个点,避免全量重算带来的卡顿。经过这样的架构设计,即便在低端机型上滚动几千根K线,帧率也能稳定保持在60fps左右。
Core GraphicsCCI指标iOS K线图绘制修改时间:2026-09-15 03:24:39