포스트

ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL

시장 국면이 왜 중요한가

같은 전략도 시장 국면에 따라 성과가 크게 다르다. 추세 추종 전략은 추세가 없는 횡보장에서 계속 손실을 낸다. 역추세 전략은 강한 추세장에서 손실을 낸다.

백테스트 결과에 국면별 성과를 분해하면 전략의 약점이 드러난다.

1
2
3
4
5
6
이 전략의 국면별 성과:
  BULL  구간: 수익률 +18.2%, MDD  -4.1%  ✅ 좋음
  BEAR  구간: 수익률  -8.4%, MDD -22.3%  ❌ 취약
  SIDEWAYS:   수익률  +1.1%, MDD  -6.7%  ⚠️ 보통

→ 하락장에서 MDD가 5배 확대됩니다. 하락장 진입 시 포지션 축소를 권장합니다.

ADX(Average Directional Index)란

ADX는 J. Welles Wilder가 개발한 추세 강도 지표다. 추세의 방향이 아니라 강도를 측정한다.

1
2
3
4
ADX < 20:  추세 없음 (횡보)
ADX 20-40: 보통 추세
ADX > 40:  강한 추세
ADX > 60:  매우 강한 추세

ADX를 방향(DI+, DI-)과 조합하면 추세의 강도와 방향을 동시에 알 수 있다.


ADX 계산 알고리즘

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
59
60
61
62
63
64
65
66
data class AdxResult(
    val adx   : Double,
    val diPlus : Double,   // +DI: 상승 방향 강도
    val diMinus: Double,   // -DI: 하락 방향 강도
)

fun computeAdx(candles: List<DailyCandle>, period: Int = 14): AdxResult {
    if (candles.size < period * 2) return AdxResult(0.0, 0.0, 0.0)

    val trList   = mutableListOf<Double>()  // True Range
    val dmPlusList  = mutableListOf<Double>()  // +DM
    val dmMinusList = mutableListOf<Double>()  // -DM

    // 1. True Range와 방향적 이동(DM) 계산
    for (i in 1 until candles.size) {
        val prev = candles[i - 1]
        val curr = candles[i]

        val tr = maxOf(
            curr.high.toDouble() - curr.low.toDouble(),
            Math.abs(curr.high.toDouble() - prev.close.toDouble()),
            Math.abs(curr.low.toDouble() - prev.close.toDouble()),
        )
        trList.add(tr)

        val upMove   = curr.high.toDouble() - prev.high.toDouble()
        val downMove = prev.low.toDouble() - curr.low.toDouble()

        dmPlusList.add(if (upMove > downMove && upMove > 0) upMove else 0.0)
        dmMinusList.add(if (downMove > upMove && downMove > 0) downMove else 0.0)
    }

    // 2. Wilder 스무딩 (지수이동평균의 변형)
    fun wilderSmooth(data: List<Double>, period: Int): List<Double> {
        val result = mutableListOf<Double>()
        var smooth = data.take(period).sum()
        result.add(smooth)
        for (i in period until data.size) {
            smooth = smooth - smooth / period + data[i]
            result.add(smooth)
        }
        return result
    }

    val smoothTR    = wilderSmooth(trList, period)
    val smoothDMPlus  = wilderSmooth(dmPlusList, period)
    val smoothDMMinus = wilderSmooth(dmMinusList, period)

    // 3. DI+ , DI- 계산
    val diPlusList  = smoothDMPlus.zip(smoothTR) { dm, tr -> if (tr == 0.0) 0.0 else dm / tr * 100 }
    val diMinusList = smoothDMMinus.zip(smoothTR) { dm, tr -> if (tr == 0.0) 0.0 else dm / tr * 100 }

    // 4. DX → ADX (Wilder 스무딩)
    val dxList = diPlusList.zip(diMinusList) { p, m ->
        val sum = p + m
        if (sum == 0.0) 0.0 else Math.abs(p - m) / sum * 100
    }

    val adxValues = wilderSmooth(dxList, period)

    return AdxResult(
        adx    = adxValues.last(),
        diPlus  = diPlusList.last(),
        diMinus = diMinusList.last(),
    )
}

Wilder 스무딩은 EMA와 유사하지만 α = 1/period를 사용한다. 표준 EMA보다 느리게 반응해 ADX에 적합하다.


국면 분류 알고리즘

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
enum class MarketRegime {
    BULL,       // 상승장
    BEAR,       // 하락장
    SIDEWAYS,   // 횡보장
    HIGH_VOL,   // 고변동성 (추세 무관)
}

fun classifyRegime(candles: List<DailyCandle>, window: Int = 60): MarketRegime {
    if (candles.size < window) return MarketRegime.SIDEWAYS

    val adxResult = computeAdx(candles.takeLast(window))

    // 변동성: 최근 20일 연환산 표준편차
    val returns = candles.takeLast(21)
        .zipWithNext { a, b -> (b.close.toDouble() - a.close.toDouble()) / a.close.toDouble() }
    val volatility = returns.standardDeviation() * Math.sqrt(252.0)

    // 60일 선형회귀 기울기 (추세 방향)
    val trend = linearRegressionSlope(candles.takeLast(window).map { it.close.toDouble() })

    // 과거 1년 변동성의 80th 백분위 계산
    val historicalVol = computeHistoricalVolatilityPercentile(candles, percentile = 0.8)

    return when {
        volatility > historicalVol -> MarketRegime.HIGH_VOL  // 변동성 최우선
        adxResult.adx < 20        -> MarketRegime.SIDEWAYS   // 추세 강도 낮음
        trend > 0 && adxResult.diPlus > adxResult.diMinus -> MarketRegime.BULL
        trend < 0 && adxResult.diMinus > adxResult.diPlus -> MarketRegime.BEAR
        else                       -> MarketRegime.SIDEWAYS
    }
}

private fun linearRegressionSlope(values: List<Double>): Double {
    val n = values.size.toDouble()
    val xMean = (n - 1) / 2
    val yMean = values.average()
    val numerator   = values.indices.sumOf { i -> (i - xMean) * (values[i] - yMean) }
    val denominator = values.indices.sumOf { i -> (i - xMean) * (i - xMean) }
    return if (denominator == 0.0) 0.0 else numerator / denominator
}

국면 기록과 백테스트 연동

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
@Service
class RegimeDetectorService(
    private val candleRepository: CandleRepository,
    private val jdbcTemplate: JdbcTemplate,
) {
    fun detectAndRecord(stockId: Long) {
        val candles = candleRepository.findRecentDays(stockId, 300)
        val regime  = classifyRegime(candles)

        // 마지막 기록과 동일한 국면이면 스킵 (변화 없음)
        val lastRegime = getLastRegime(stockId)
        if (lastRegime == regime) return

        jdbcTemplate.update("""
            INSERT INTO regime_history (stock_id, regime, started_at, adx, volatility)
            VALUES (?, ?, NOW(), ?, ?)
        """, stockId, regime.name, computeAdx(candles).adx,
             computeVolatility(candles))
    }

    // 백테스트 결과에서 각 거래가 어떤 국면에서 발생했는지 맵핑
    fun enrichWithRegime(trades: List<SimulatedTrade>, stockId: Long): List<TradeWithRegime> {
        return trades.map { trade ->
            val regimeAtEntry = getRegimeAt(stockId, trade.entryDate)
            trade.copy(regime = regimeAtEntry)
        }
    }
}

국면별 성과 분해 (PhasePerformance)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun computePhasePerformance(
    trades: List<TradeWithRegime>
): Map<MarketRegime, PhaseStats> {
    return MarketRegime.entries.associateWith { regime ->
        val regimeTrades = trades.filter { it.regime == regime }
        if (regimeTrades.isEmpty()) return@associateWith PhaseStats.empty()

        PhaseStats(
            tradeCount    = regimeTrades.size,
            totalReturn   = regimeTrades.sumOf { it.netPnl } / initialCapital,
            winRate       = regimeTrades.count { it.netPnl > 0 }.toDouble() / regimeTrades.size,
            mdd           = calculateMdd(regimeTrades),
        )
    }
}

정리

  • ADX는 추세 방향이 아닌 추세 강도를 측정한다. ADX < 20이면 추세 없음.
  • 분류 우선순위: HIGH_VOL → SIDEWAYS(ADX<20) → BULL/BEAR(방향 판단)
  • 60일 선형회귀 기울기로 추세 방향을, DI+/DI-로 매수/매도 압력을 확인한다.
  • 백테스트 결과에 국면별 성과를 분해하면 전략의 환경적 취약점이 드러난다.

다음 편에서는 모의투자 세금 시뮬레이션인 Tax Harvesting — 손익통산으로 세금 줄이기를 다룬다.

  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 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다