포스트

ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥

차트 패턴이란

기술적 분석(Technical Analysis)에서 차트 패턴은 과거 가격 움직임의 반복 구조다. 패턴이 완성되면 다음 방향을 예측하는 데 사용한다.

monticker가 감지하는 5가지 패턴:

패턴의미신호 방향
HEAD_AND_SHOULDERS머리·어깨 모양, 상승 끝하락 반전
DOUBLE_BOTTOM이중 바닥 W자상승 반전
DOUBLE_TOP이중 천장 M자하락 반전
ASCENDING_TRIANGLE고점 수평·저점 상승상승 지속
DESCENDING_TRIANGLE고점 하락·저점 수평하락 지속

왜 원시 가격이 아닌 ZigZag인가

원시 캔들 데이터로 패턴을 찾으면 노이즈가 많다. 매 틱의 작은 등락이 패턴 감지를 방해한다.

ZigZag 알고리즘은 의미 있는 전환점(swing point) 만 추출해 노이즈를 제거한다.

1
2
3
4
5
6
원시 가격:  73200 → 73100 → 73300 → 73250 → 73400 → 73100 → ...
                                          (노이즈 많음)

ZigZag:     73200 ──────────────────── 73400
                                             ↘ 73100
                         (의미 있는 고점/저점만)

ZigZag 알고리즘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
data class SwingPoint(
    val index : Int,
    val price : BigDecimal,
    val type  : SwingType,  // HIGH or LOW
    val date  : LocalDate,
)

enum class SwingType { HIGH, LOW }

fun zigZag(candles: List<DailyCandle>, thresholdPct: Double = 0.05): List<SwingPoint> {
    if (candles.size < 3) return emptyList()

    val swings = mutableListOf<SwingPoint>()
    var lastSwingIndex = 0
    var lastSwingPrice = candles[0].close
    var direction: SwingType? = null

    candles.forEachIndexed { i, candle ->
        if (i == 0) return@forEachIndexed

        val changeFromLast = (candle.close - lastSwingPrice).toDouble() / lastSwingPrice.toDouble()

        when {
            direction == null -> {
                // 방향 미정 → 첫 번째 의미 있는 변동으로 방향 결정
                if (Math.abs(changeFromLast) >= thresholdPct) {
                    direction = if (changeFromLast > 0) SwingType.HIGH else SwingType.LOW
                    swings.add(SwingPoint(0, candles[0].close, direction!!.flip(), candles[0].date))
                }
            }
            direction == SwingType.HIGH && changeFromLast <= -thresholdPct -> {
                // 상승 → 하락 전환: 고점 기록
                swings.add(SwingPoint(lastSwingIndex, lastSwingPrice, SwingType.HIGH, candles[lastSwingIndex].date))
                direction = SwingType.LOW
                lastSwingIndex = i
                lastSwingPrice = candle.close
            }
            direction == SwingType.LOW && changeFromLast >= thresholdPct -> {
                // 하락 → 상승 전환: 저점 기록
                swings.add(SwingPoint(lastSwingIndex, lastSwingPrice, SwingType.LOW, candles[lastSwingIndex].date))
                direction = SwingType.HIGH
                lastSwingIndex = i
                lastSwingPrice = candle.close
            }
            direction == SwingType.HIGH && candle.close > lastSwingPrice -> {
                // 상승 지속: 고점 갱신
                lastSwingIndex = i
                lastSwingPrice = candle.close
            }
            direction == SwingType.LOW && candle.close < lastSwingPrice -> {
                // 하락 지속: 저점 갱신
                lastSwingIndex = i
                lastSwingPrice = candle.close
            }
        }
    }
    return swings
}

thresholdPct = 0.05는 5% 이상의 가격 변동만 swing point로 인정한다. 값이 작을수록 민감하고, 클수록 큰 추세만 감지한다.


이중 바닥 패턴 감지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
fun detectDoubleBottom(swings: List<SwingPoint>): PatternMatch? {
    // 최근 5개 swing에서 [LOW, HIGH, LOW] 시퀀스 탐색
    if (swings.size < 5) return null

    val recent = swings.takeLast(5)
    val lows  = recent.filter { it.type == SwingType.LOW }
    val highs = recent.filter { it.type == SwingType.HIGH }

    if (lows.size < 2 || highs.isEmpty()) return null

    val bottom1 = lows[lows.size - 2]
    val neckline = highs.last()
    val bottom2 = lows.last()

    // 순서 확인: bottom1 → neckline → bottom2
    if (bottom1.index >= neckline.index || neckline.index >= bottom2.index) return null

    val bottom1Price = bottom1.price.toDouble()
    val bottom2Price = bottom2.price.toDouble()
    val necklinePrice = neckline.price.toDouble()

    // 두 저점이 비슷해야 함 (±3%)
    val bottomDiff = Math.abs(bottom1Price - bottom2Price) / bottom1Price
    if (bottomDiff > 0.03) return null

    // 넥라인이 저점보다 5% 이상 높아야 함
    val necklineAbove = (necklinePrice - bottom1Price) / bottom1Price
    if (necklineAbove < 0.05) return null

    // 완성도 점수 계산
    val symmetryScore = (1 - bottomDiff / 0.03) * 40  // 두 저점 유사도
    val depthScore    = minOf(necklineAbove / 0.10, 1.0) * 30  // 패턴 깊이
    val recencyScore  = 30.0  // 최근 데이터에서 발견

    val confidence = (symmetryScore + depthScore + recencyScore).toInt().coerceIn(0, 100)

    return PatternMatch(
        type       = PatternType.DOUBLE_BOTTOM,
        confidence = confidence,
        bottom1    = bottom1,
        neckline   = neckline,
        bottom2    = bottom2,
        signal     = TechnicalSignal.BULLISH,
    )
}

헤드앤숄더 패턴 감지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
fun detectHeadAndShoulders(swings: List<SwingPoint>): PatternMatch? {
    if (swings.size < 7) return null

    val recent = swings.takeLast(7)
    val highs = recent.filter { it.type == SwingType.HIGH }
    val lows  = recent.filter { it.type == SwingType.LOW }

    if (highs.size < 3 || lows.size < 2) return null

    val leftShoulder  = highs[highs.size - 3]
    val head          = highs[highs.size - 2]
    val rightShoulder = highs.last()
    val leftNeckline  = lows[lows.size - 2]
    val rightNeckline = lows.last()

    // 머리가 두 어깨보다 높아야 함
    if (head.price <= leftShoulder.price || head.price <= rightShoulder.price) return null

    // 두 어깨가 비슷한 높이여야 함 (±5%)
    val shoulderDiff = Math.abs(
        leftShoulder.price.toDouble() - rightShoulder.price.toDouble()
    ) / leftShoulder.price.toDouble()
    if (shoulderDiff > 0.05) return null

    // 두 넥라인이 비슷한 높이여야 함
    val necklineDiff = Math.abs(
        leftNeckline.price.toDouble() - rightNeckline.price.toDouble()
    ) / leftNeckline.price.toDouble()
    if (necklineDiff > 0.05) return null

    val headHeight = (head.price.toDouble() - leftShoulder.price.toDouble()) / leftShoulder.price.toDouble()
    val symmetryScore = (1 - shoulderDiff / 0.05) * 50
    val heightScore   = minOf(headHeight / 0.10, 1.0) * 50

    val confidence = (symmetryScore + heightScore).toInt().coerceIn(0, 100)

    return PatternMatch(
        type       = PatternType.HEAD_AND_SHOULDERS,
        confidence = confidence,
        signal     = TechnicalSignal.BEARISH,
    )
}

이벤트로 기록

패턴이 완성도 점수 70 이상이면 stock_events에 이벤트를 기록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fun detectAndRecord(stockId: Long) {
    val candles = candleRepository.findRecentDays(stockId, 120)
    val swings  = zigZag(candles)

    listOf(
        detectDoubleBottom(swings),
        detectDoubleTop(swings),
        detectHeadAndShoulders(swings),
        detectAscendingTriangle(swings),
        detectDescendingTriangle(swings),
    ).filterNotNull()
        .filter { it.confidence >= 70 }
        .forEach { match ->
            stockEventRepository.insertIfNotExists(StockEvent(
                stockId   = stockId,
                eventType = EventType.PATTERN_DETECTED,
                metadata  = objectMapper.writeValueAsString(match),
                importance = match.confidence,
            ))
        }
}

정리

  • ZigZag는 5% 이상 전환만 추출해 노이즈를 제거한다. 임계값이 패턴 민감도를 결정한다.
  • 패턴 완성도 점수(0~100)는 두 저점/고점의 대칭성, 패턴 깊이, 최근성으로 계산한다.
  • 점수 70 이상인 패턴만 stock_events에 기록해 타임라인에 표시한다.

다음 편에서는 ADX 지표로 현재 시장이 상승장·하락장·횡보장·고변동성 중 어느 국면인지 분류하는 시장 국면 감지(Regime Detection) 를 다룬다.

  1. 1 가격이 아니라 이벤트를 팔자 — monticker 설계 철학
  2. 2 모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기
  3. 3 TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계
  4. 4 Go goroutine으로 202개 종목 동시 수집하기 — Market Gateway 설계
  5. 5 Kafka로 시세 파이프라인 분리하기 — 토픽 설계와 at-least-once
  6. 6 Netty로 수만 연결에 시세 브로드캐스트하기 — NioEventLoopGroup 리액터 패턴
  7. 7 EMA 기반 이상 탐지 — 가격 급등과 거래량 서지 실시간 감지
  8. 8 TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지
  9. 9 주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙
  10. 10 잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계
  11. 11 감정 태그 × 수익률 — 투자 습관을 데이터로 기록하기
  12. 12 룰 엔진: RSI·MACD 조건식을 JSON DSL로 — Quant Lab 설계
  13. 13 백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산
  14. 14 전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어
  15. 15 Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법
  16. 16 Kelly Criterion: 수학적 파산 방지 베팅 비율 — Half Kelly와 백테스트 연동
  17. 17 ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥
  18. 18 ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL
  19. 19 손익통산으로 세금 줄이기 — Tax-Loss Harvesting 시뮬레이션
  20. 20 MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험
  21. 21 OpenTelemetry + Jaeger로 분산 추적 — 시세 파이프라인 지연 측정
  22. 22 Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다