포스트

Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법

Markowitz 포트폴리오 이론

해리 마코위츠가 1952년 발표한 평균-분산 최적화(Mean-Variance Optimization)는 현대 포트폴리오 이론의 기초다.

핵심 아이디어: 같은 기대 수익률이라면 변동성이 낮은 포트폴리오가 더 좋다.

수학적으로 표현하면:

1
2
3
4
minimize   wᵀΣw          (포트폴리오 분산 최소화)
subject to wᵀμ = r*      (목표 수익률 r* 달성)
           Σwᵢ = 1       (비중 합 = 1)
           wᵢ ≥ 0        (공매도 불가)
  • w: 각 종목의 비중 벡터
  • Σ: 공분산 행렬
  • μ: 각 종목의 기대 수익률 벡터

수익률 데이터 준비

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
@Service
class PortfolioOptimizer(
    private val candleRepository: CandleRepository,
) {
    fun optimize(stockIds: List<Long>, targetReturn: Double): OptimizationResult {
        // 최근 252일(1년) 일별 수익률 계산
        val returnMatrix = buildReturnMatrix(stockIds, days = 252)

        val mu  = returnMatrix.columnMeans()        // 종목별 평균 일별 수익률
        val cov = returnMatrix.covarianceMatrix()    // 공분산 행렬

        val weights = minimizeVariance(cov, mu, targetReturn)
        return buildResult(weights, mu, cov, stockIds)
    }

    private fun buildReturnMatrix(stockIds: List<Long>, days: Int): Matrix {
        val returns = stockIds.map { stockId ->
            val candles = candleRepository.findRecentDays(stockId, days + 1)
            candles.zipWithNext { a, b ->
                (b.close.toDouble() - a.close.toDouble()) / a.close.toDouble()
            }
        }
        return Matrix(returns)
    }
}

프로젝션 경사하강법 (Projected Gradient Descent)

일반적으로 이차계획법(Quadratic Programming) 솔버가 필요하지만, 외부 라이브러리 없이 프로젝션 경사하강법으로 근사할 수 있다.

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
fun minimizeVariance(
    cov: Matrix,
    mu: DoubleArray,
    targetReturn: Double,
    maxIterations: Int = 1000,
    learningRate: Double = 0.01,
): DoubleArray {
    val n = mu.size
    // 초기 비중: 균등 배분
    var w = DoubleArray(n) { 1.0 / n }

    repeat(maxIterations) {
        // 분산에 대한 기울기: ∂(wᵀΣw)/∂w = 2Σw
        val gradient = cov.times(w).map { it * 2.0 }.toDoubleArray()

        // 기울기 방향으로 한 스텝
        w = w.zip(gradient) { wi, gi -> wi - learningRate * gi }.toDoubleArray()

        // 제약 조건 투영: Σw=1, w≥0 (simplex 투영)
        w = projectToSimplex(w)

        // 목표 수익률 제약 보정
        w = adjustForTargetReturn(w, mu, targetReturn)
    }

    return w
}

Simplex 투영 알고리즘

Σwᵢ=1, wᵢ≥0 제약 조건의 실현가능 영역은 단순체(simplex)다. 이 공간으로의 투영 알고리즘을 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun projectToSimplex(v: DoubleArray): DoubleArray {
    val n = v.size
    val sorted = v.sortedDescending()

    var rho = 0
    var sum = 0.0
    for (i in sorted.indices) {
        sum += sorted[i]
        if (sorted[i] - (sum - 1.0) / (i + 1) > 0) rho = i
    }
    val theta = (sorted.take(rho + 1).sum() - 1.0) / (rho + 1)

    return v.map { maxOf(it - theta, 0.0) }.toDoubleArray()
}

이 알고리즘은 Duchi et al. (2008)의 O(n log n) 투영 알고리즘이다. 내부 루프 없이 정렬 한 번으로 simplex 투영을 계산한다.


효율적 프론티어

목표 수익률을 여러 값으로 스윕하면 위험-수익 곡선(효율적 프론티어)을 얻는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun computeFrontier(stockIds: List<Long>, points: Int = 20): List<FrontierPoint> {
    val returnMatrix = buildReturnMatrix(stockIds, days = 252)
    val mu  = returnMatrix.columnMeans()
    val cov = returnMatrix.covarianceMatrix()

    val minReturn = mu.min()!!
    val maxReturn = mu.max()!!

    return (0 until points).map { i ->
        val targetReturn = minReturn + (maxReturn - minReturn) * i / (points - 1)
        val weights = minimizeVariance(cov, mu, targetReturn)
        val portfolioReturn = weights.zip(mu) { w, r -> w * r }.sum()
        val portfolioVariance = weights.dot(cov.times(weights))

        FrontierPoint(
            targetReturn     = targetReturn * 252,         // 연환산
            portfolioReturn  = portfolioReturn * 252,
            risk             = Math.sqrt(portfolioVariance * 252),  // 연환산 표준편차
            weights          = weights.zip(stockIds).associate { (w, id) -> id to w },
        )
    }
}

현재 포트폴리오 위치 표시

사용자가 보유한 포트폴리오가 효율적 프론티어 위에 있는지 아래에 있는지를 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
data class OptimizationResult(
    val frontier: List<FrontierPoint>,
    val currentPortfolio: PortfolioStats,
    val suggestion: String,
)

fun buildResult(...): OptimizationResult {
    val currentStats = calculateCurrentStats(currentWeights, mu, cov)
    val nearestFrontierPoint = frontier.minByOrNull {
        abs(it.portfolioReturn - currentStats.expectedReturn)
    }!!

    val potentialImprovement = nearestFrontierPoint.portfolioReturn - currentStats.expectedReturn

    val suggestion = if (potentialImprovement > 0.005) {
        "현재 포트폴리오는 효율적 프론티어 아래에 있습니다. " +
        "비중 조정 시 동일 위험에서 연 ${String.format("%.1f", potentialImprovement * 100)}%p 추가 수익 가능합니다."
    } else {
        "현재 포트폴리오가 이미 효율적 프론티어에 근접합니다."
    }

    return OptimizationResult(frontier, currentStats, suggestion)
}

응답 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
  "frontier": [
    {
      "portfolioReturn": 0.06,
      "risk": 0.12,
      "weights": { "1": 0.45, "2": 0.30, "3": 0.25 }
    },
    {
      "portfolioReturn": 0.10,
      "risk": 0.18,
      "weights": { "1": 0.20, "2": 0.15, "3": 0.65 }
    }
  ],
  "currentPortfolio": {
    "expectedReturn": 0.07,
    "risk": 0.16
  },
  "suggestion": "현재 포트폴리오는 효율적 프론티어 아래에 있습니다. 비중 조정 시 동일 위험에서 연 1.2%p 추가 수익 가능합니다."
}

한계와 주의사항

Markowitz 최적화에는 알려진 한계가 있다.

  1. 과거 데이터 의존: 공분산 행렬을 과거 수익률로 추정한다. 미래 상관관계가 바뀌면 최적 비중이 달라진다.
  2. 추정 오차 증폭: 기대 수익률 추정이 조금만 틀려도 최적 비중이 크게 달라진다 (error maximization 문제).
  3. 거래 비용 무시: 최적 비중으로 리밸런싱하는 거래 비용이 개선 효과를 상회할 수 있다.

monticker에서는 이 내용을 UI에 명시적으로 고지하고, “교육 목적 시뮬레이션”임을 강조한다.


정리

  • 외부 QP 솔버 없이 프로젝션 경사하강법 + simplex 투영으로 Markowitz 최적화를 구현한다.
  • 효율적 프론티어는 목표 수익률을 스윕해 (위험, 수익) 곡선을 계산한다.
  • 과거 데이터 의존과 추정 오차 증폭 문제를 UI에서 명시적으로 고지한다.

다음 편에서는 백테스트 결과의 승률·손익비로 수학적 파산 방지 베팅 비율(Kelly Criterion) 을 계산하는 방법을 다룬다.

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

댓글

아직 댓글이 없습니다