Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

포트폴리오 리밸런싱

포트폴리오 이론에 관한 주제를 이어 가자면, 앞에서 이미 일련의 제약 조건이 주어졌을 때 포트폴리오를 최적화하는 방법을 논의했습니다. 이제 원하는 자산 배분을 유지하도록 cvxopt를 사용해 포트폴리오를 리밸런싱하는 방법을 논의하겠습니다.

예를 들어, 현재 포트폴리오로 존재하는 간단한 포트폴리오에서 시작하여 이를 목표 포트폴리오로 리밸런싱하겠습니다. 지난 글에서 논의한 최적화 기법은 최적의 포트폴리오 가중치를 결정하지만, 이러한 가중치를 시간이 지나도 유지하려면 거래 비용과 시장 충격을 신중하게 고려해야 합니다. 새로운 투자 논리를 반영하거나 변화하는 시장 상황에 대응하기 위해 시간이 지남에 따라 포트폴리오 가중치를 변경해야 합니다.

리밸런싱 문제는 서로 상충하는 세 가지 목표의 균형을 맞추는 이차 프로그래밍 문제로 정식화할 수 있습니다.

  1. 목표 가중치와의 거리 최소화
  2. 거래 비용 최소화
  3. 위험-수익 특성 유지

수학적 정식화는 다음과 같습니다.

여기서:

  • 는 최적화에서 얻은 목표 가중치입니다
  • 는 현재 가중치입니다
  • 은 위험 페널티를 조절합니다
  • 는 L2 거래 비용(이차 비용)을 조절합니다
  • 은 L1 거래 비용(선형 비용)을 조절합니다

실용적인 구현은 다음과 같습니다.

import numpy as np
from cvxopt import matrix, solvers

def rebalance_portfolio(current_weights, target_weights, covariance_matrix, lambda_risk=1.0, lambda_trade=1.0, lambda_cost=0.01):
    n = len(current_weights)

    # Convert inputs to cvxopt format
    P = matrix(lambda_risk * covariance_matrix.values +
              lambda_trade * np.eye(n))

    # Linear cost term
    q = matrix(lambda_cost * np.ones(n) -
              2 * lambda_trade * current_weights)

    # Constraints
    G = matrix(np.vstack((-np.eye(n),  # Long only constraint
                         np.eye(n))))   # Upper bound constraint
    h = matrix(np.hstack((np.zeros(n),  # w_i >= 0
                         np.ones(n))))   # w_i <= 1

    A = matrix(1.0, (1, n))  # Sum of weights = 1
    b = matrix(1.0)

    # Solve the QP problem
    sol = solvers.qp(P, q, G, h, A, b)

    if sol['status'] != 'optimal':
        raise ValueError("Optimization failed to converge")

    new_weights = np.array(sol['x']).flatten()

    # Calculate turnover
    turnover = np.sum(np.abs(new_weights - current_weights))

    return new_weights, turnover

간단한 예로, 4개 종목으로 구성된 포트폴리오를 각 종목의 비중이 25%로 동일해지도록 리밸런싱해 봅시다.

current_weights = np.array([0.3, 0.3, 0.2, 0.2])  # Current portfolio weights
target_weights = np.array([0.25, 0.25, 0.25, 0.25])  # Target weights

new_weights, turnover = rebalance_portfolio(
    current_weights,
    target_weights,
    covariance_matrix
)

print("Rebalancing Results:")
print("Current weights:", current_weights)
print("Target weights:", target_weights)
print("New weights:", new_weights.round(3))
print("Turnover:", f"{turnover:.2%}")

이렇게 하면 목표 가중치에 도달하기 위해 각 종목을 얼마나 매수하거나 매도해야 하는지 구할 수 있습니다. 먼저, 거래 규모에 비례하는 선형 비용과 시장 충격을 반영하는 이차 비용을 모두 포함하여 거래 비용을 고려합니다. 리밸런싱된 포트폴리오는 목표 포트폴리오와 유사한 위험 특성을 유지하도록 설계됩니다. 마지막으로, 회전율 제어는 리밸런싱의 이점과 그에 따르는 비용 사이의 균형을 맞추려 한다는 점에서 최적화 과정의 핵심 요소입니다.

람다 매개변수로 제약 조건을 조정할 수도 있습니다.

  • lambda_risk가 클수록 위험 특성을 맞추는 데 더 큰 비중을 둡니다
  • lambda_trade가 클수록 회전율이 줄어듭니다
  • lambda_cost가 클수록 최적화가 거래 비용에 더 민감해집니다

섹터 노출

실제로는 위험 및 수익 목표를 충족하고 있는지 확인하기 위해 훨씬 더 많은 추가 제약 조건을 두고 싶을 것입니다. 실무에서는 다음과 같은 추가 제약 조건을 더하고 싶을 수 있습니다.

  • 섹터 노출 한도
  • 최대 포지션 규모
  • 최소 거래 규모
  • 자산별로 다른 거래 비용

이러한 요소는 최적화 문제에 제약 조건을 더 추가하여 반영할 수 있습니다. 예를 들어 다음과 같은 제약 조건을 추가해 봅시다:

  • 어떤 섹터도 포트폴리오의 30%를 초과할 수 없습니다
  • 총 회전율(절대 변화량의 합)은 20%를 초과할 수 없습니다
  • 개별 포지션 규모는 0%에서 100% 사이로 유지됩니다
  • 비중의 합은 100%입니다

S&P 500의 여러 섹터에서 고른 10개 종목으로 좀 더 현실적인 예제를 다루어 봅시다:

import pandas as pd

stocks = {
    'AAPL': 'Technology',
    'MSFT': 'Technology',
    'JPM':  'Financials',
    'T':    'Financials',
    'JNJ':  'Healthcare',
    'ABBV': 'Healthcare',
    'PG':   'Consumer Staples',
    'KO':   'Consumer Staples',
    'XOM':  'Energy',
    'CVX':  'Energy',
    'HD':   'Consumer Discretionary',
    'NEE':  'Utilities',
    'LIN':  'Materials',
    'UNP':  'Industrials',
    'VZ':   'Communications'
}

# Read in historical stock prices from a CSV file
data = pd.read_csv('stock_prices.csv', index_col='Date', parse_dates=True)

# Calculate returns and covariance matrix
returns = data.pct_change().dropna()
covariance_matrix = returns.cov() * 252  # Annualized covariance

이제 섹터 제약 조건과 좀 더 현실적인 거래 비용을 포함하도록 리밸런싱 함수를 개선해 봅시다:

def rebalance_portfolio_with_constraints(
    current_weights,
    target_weights,
    covariance_matrix,
    sector_map,
    transaction_costs,
    max_sector_exposure=0.30,
    max_turnover=0.20,
    lambda_risk=1.0,
    lambda_trade=1.0
):
    n = len(current_weights)

    # Create sector constraint matrix
    unique_sectors = list(set(sector_map.values()))
    sector_constraints = np.zeros((len(unique_sectors), n))

    for i, sector in enumerate(unique_sectors):
        for j, stock in enumerate(sector_map.keys()):
            if sector_map[stock] == sector:
                sector_constraints[i, j] = 1

    # Convert inputs to cvxopt format
    P = matrix(lambda_risk * covariance_matrix.values +
              lambda_trade * np.eye(n))

    # Linear cost term including transaction costs
    q = matrix(-2 * lambda_trade * current_weights)

    # Constraints matrix
    G = matrix(np.vstack([
        -np.eye(n),                # Long only constraint
        np.eye(n),                 # Upper bound constraint
        sector_constraints,        # Sector exposure constraints
        -sector_constraints,       # Minimum sector exposure
        np.eye(n),                 # Positive turnover
        -np.eye(n)                 # Negative turnover
    ]))

    # Constraints vector
    h = matrix(np.hstack([
        np.zeros(n),              # Long only
        np.ones(n),               # Upper bound
        np.repeat(max_sector_exposure, len(unique_sectors)),  # Max sector
        np.zeros(len(unique_sectors)),                        # Min sector
        current_weights + max_turnover,  # Max positive turnover
        -current_weights + max_turnover  # Max negative turnover
    ]))

    # Sum of weights = 1
    A = matrix(1.0, (1, n))
    b = matrix(1.0)

    # Solve the QP problem
    sol = solvers.qp(P, q, G, h, A, b)

    if sol['status'] != 'optimal':
        raise ValueError("Optimization failed to converge")

    new_weights = np.array(sol['x']).flatten()
    turnover = np.sum(np.abs(new_weights - current_weights))

    # Calculate transaction costs
    total_cost = sum(abs(new_weights[i] - current_weights[i]) * transaction_costs[ticker]
                    for i, ticker in enumerate(sector_map.keys()))

    return new_weights, turnover, total_cost

자, 이제 실제 데이터로 이를 실행해 봅시다. 현재 포트폴리오 비중과 지난 글에서 구한 목표 포트폴리오 비중을 사용하겠습니다:

# What we have
current_portfolio = {
    'AAPL': 0.15, # 15% Apple
    'MSFT': 0.12, # 12% Microsoft
    'JPM':  0.10, # 10% JPMorgan
    'T':    0.08, # 8%  AT&T
    'JNJ':  0.12, # 12% Johnson & Johnson
    'ABBV': 0.08, # 8%  AbbVie
    'PG':   0.10, # 10% Procter & Gamble
    'KO':   0.08, # 8%  Coca-Cola
    'XOM':  0.09, # 9%  Exxon Mobil
    'CVX':  0.08, # 8%  Chevron
    'HD':   0.07, # 7%  Home Depot
    'NEE':  0.06, # 6%  NextEra Energy
    'LIN':  0.05, # 5%  Linde
    'UNP':  0.06, # 6%  Union Pacific
    'VZ':   0.05  # 5%  Verizon
}

# What we want
target_portfolio = {
    'AAPL': 0.10, # 10% Apple
    'MSFT': 0.10, # 10% Microsoft
    'JPM':  0.10, # 10% JPMorgan
    'T':    0.10, # 10% AT&T
    'JNJ':  0.10, # 10% Johnson & Johnson
    'ABBV': 0.10, # 10% AbbVie
    'PG':   0.10, # 10% Procter & Gamble
    'KO':   0.10, # 10% Coca-Cola
    'XOM':  0.10, # 10% Exxon Mobil
    'CVX':  0.10, # 10% Chevron
    'HD':   0.0,  # 0%  Home Depot
    'NEE':  0.0,  # 0%  NextEra Energy
    'LIN':  0.0,  # 0%  Linde
    'UNP':  0.0,  # 0%  Union Pacific
    'VZ':   0.0   # 0%  Verizon
}

# Dummy transaction costs (will vary by stock liquidity)
transaction_costs = {
    'AAPL': 0.001,   # 10 bps
    'MSFT': 0.001,   # 10 bps
    'JPM':  0.0012,  # 12 bps
    'T':    0.0012,  # 12 bps
    'JNJ':  0.001,   # 10 bps
    'ABBV': 0.001,   # 10 bps
    'PG':   0.001,   # 10 bps
    'KO':   0.001,   # 10 bps
    'XOM':  0.0015,  # 15 bps
    'CVX':  0.0015,  # 15 bps
    'HD':   0.0012,  # 12 bps
    'NEE':  0.0015,  # 15 bps
    'LIN':  0.0018,  # 18 bps
    'UNP':  0.0015,  # 15 bps
    'VZ':   0.0018   # 18 bps
}

# Example usage
current_weights = np.array([current_portfolio[ticker] for ticker in stocks.keys()])
target_weights = np.array([target_portfolio[ticker] for ticker in stocks.keys()])


new_weights, turnover, total_cost = rebalance_portfolio_with_constraints(
    current_weights=current_weights,
    target_weights=target_weights,
    covariance_matrix=covariance_matrix,
    sector_map=stocks,
    transaction_costs=transaction_costs,
    max_sector_exposure=0.30,
    max_turnover=0.20
)

# Print results with sector analysis
results = pd.DataFrame({
    'Stock': list(stocks.keys()),
    'Sector': list(stocks.values()),
    'Current Weight': current_weights,
    'Target Weight': target_weights,
    'New Weight': new_weights.round(4)
})

print("Rebalancing Results:")
print(results)
print(f"Turnover: {turnover:.2%}")
print(f"Transaction Costs: ${total_cost*1000000:.2f} per $1M traded")

# Sector exposure analysis
print("Sector Exposures:")
sector_exposures = results.groupby('Sector')['New Weight'].sum().round(4)
print(sector_exposures)

자, 이제 훨씬 더 현실적입니다. 해가 섹터 제약 조건을 준수하고 있으며 거래 비용이 최소화되고 있음을 확인할 수 있습니다. 추가된 제약 조건 때문에, 특히 포지션 규모가 클 때, 포트폴리오의 기대 수익률이 목표 수익률보다 낮아지는 경우가 꽤 자주 있습니다.

더 정교한 모델이라면 대규모 거래를 할 때 발생하는 슬리피지와 그 거래가 주식의 시장 가격에 미칠 수 있는 영향까지 고려할 것입니다. 이는 최적화 문제의 직접적인 제약 조건은 아니지만, 다른 모델을 사용하여 완화하는 데 도움을 받을 수 있는 부분입니다.


원문 © Stephen Diehl, stephendiehl.com · 글 CC BY-SA 4.0, 코드 MIT · Stephen Diehl은 런던의 소프트웨어 엔지니어이자 작가입니다(저자가 정한 소개 문구). 이 글은 저자가 개인 자격으로 쓴 것이며 어떤 조직과의 연관도 밝히거나 암시해서는 안 됩니다 (라이선스와 고지).
이 페이지는 원문을 한국어로 옮기며 변경한 비공식 번역이며 같은 CC BY-SA 4.0으로 제공합니다 · yeokja 한국어 번역 · Anthropic 사의 claude-opus-5-5 모델을 활용하여 번역되었으며 학습을 모두 비허용한 상태로 작업하였습니다.