Skip to content

Split View: ETF 리밸런싱 자동화 가이드: 규칙 기반 투자 운영

|

ETF 리밸런싱 자동화 가이드: 규칙 기반 투자 운영

ETF 리밸런싱 자동화 가이드: 규칙 기반 투자 운영

리밸런싱을 자동화해야 하는 이유

포트폴리오를 구성하고 매수까지는 누구나 한다. 문제는 그 다음이다. 6개월이 지나면 주식이 올라 비중이 80%가 되고 채권은 10%로 쪼그라든다. "조금 더 오를 것 같은데"라는 감정이 개입하면 리밸런싱은 영원히 미뤄진다.

Vanguard의 2023년 연구 The Case for Rebalancing에 따르면, 리밸런싱을 하지 않은 포트폴리오는 10년 후 위험(표준편차)이 원래 설계보다 30-50% 높아졌다. 수익이 문제가 아니라 위험 관리가 무너진 것이다.

자동화의 목적은 수익 극대화가 아니다. 의사결정에서 감정을 제거하고, 규칙을 일관되게 실행하는 것이 핵심이다. William Bernstein의 The Intelligent Asset Allocator에서 강조하듯, "리밸런싱은 싸게 사고 비싸게 파는 행위를 기계적으로 반복하는 것"이다.

리밸런싱 방식 비교: 시간 기반 vs 편차 기반 vs 하이브리드

세 가지 방식 비교

방식트리거장점단점적합한 경우
시간 기반매 분기/반기/연간 고정일단순, 예측 가능시장 급변 시 대응 늦음소규모 포트폴리오, 초보자
편차 기반목표 비중 대비 ±N% 이탈 시시장 변동에 민감하게 대응빈번한 매매 → 비용 증가변동성 큰 자산 보유 시
하이브리드분기 점검 + 편차 5% 초과 시 즉시비용과 위험 균형규칙 복잡도 증가실전 최적, 대부분 추천

하이브리드 방식 상세

하이브리드 방식은 두 가지 규칙을 동시에 적용한다:

  1. 정기 점검: 매 분기 첫 영업일에 전체 포트폴리오 비중을 확인한다
  2. 긴급 리밸런싱: 어떤 자산이든 목표 비중 대비 ±5% 이상 이탈하면 즉시 리밸런싱한다

예시: 목표 비중이 주식 60% / 채권 30% / 금 10%인 포트폴리오에서 주식이 66%를 넘거나 54% 밑으로 내려가면 분기를 기다리지 않고 바로 리밸런싱한다.

포트폴리오 설정 파일 설계

리밸런싱 자동화의 첫 단계는 투자 규칙을 코드로 문서화하는 것이다. YAML 파일로 관리하면 변경 이력을 Git으로 추적할 수 있다.

# portfolio_config.yaml
# 최종 수정: 2026-03-04
# 변경 사유: 2026년 자산배분 비중 조정 (채권 비중 확대)

portfolio:
  name: '2026 장기 성장형'
  owner: 'youngjukim'
  base_currency: 'KRW'
  broker: '한국투자증권' # 또는 키움, 미래에셋 등

  # 목표 자산 배분
  allocation:
    core:
      weight: 0.70
      assets:
        - ticker: 'TIGER 미국S&P500'
          code: '360750'
          target: 0.35
          min: 0.30
          max: 0.40
        - ticker: 'KODEX 200'
          code: '069500'
          target: 0.15
          min: 0.10
          max: 0.20
        - ticker: 'TIGER 미국채10년선물'
          code: '305080'
          target: 0.15
          min: 0.10
          max: 0.20
        - ticker: 'KODEX 종합채권(AA-이상)액티브'
          code: '443160'
          target: 0.05
          min: 0.02
          max: 0.08

    satellite:
      weight: 0.30
      assets:
        - ticker: 'TIGER 반도체'
          code: '091230'
          target: 0.10
          min: 0.05
          max: 0.15
        - ticker: 'KODEX 2차전지산업'
          code: '305720'
          target: 0.10
          min: 0.05
          max: 0.15
        - ticker: 'TIGER 금은선물(H)'
          code: '319640'
          target: 0.10
          min: 0.05
          max: 0.15

  # 리밸런싱 규칙
  rebalancing:
    strategy: 'hybrid'
    scheduled:
      frequency: 'quarterly'
      months: [1, 4, 7, 10] # 1월, 4월, 7월, 10월 첫 영업일
      day: 'first_business_day'
    drift_trigger:
      threshold: 0.05 # ±5% 이탈 시 긴급 리밸런싱
      check_frequency: 'weekly' # 매주 월요일 확인
    constraints:
      min_trade_amount: 100000 # 최소 거래 금액 10만원
      avoid_ex_dividend_dates: true # 배당 기준일 전후 매매 회피
      tax_loss_harvesting: false # 한국에서는 실효성 낮음

  # 비용 설정
  costs:
    etf_commission_rate: 0.00015 # 0.015% (온라인 증권사 기준)
    etf_tax_rate: 0.0 # 국내 ETF 매매차익 비과세 (2025 기준)
    overseas_etf_tax_rate: 0.22 # 해외 ETF 양도소득세 22%
    slippage_estimate: 0.001 # 0.1% 슬리피지 추정

리밸런싱 엔진 구현 (Python)

핵심 로직: 현재 비중 확인 → 편차 계산 → 매매 주문 생성

"""
ETF 리밸런싱 엔진
- 포트폴리오 설정 파일을 읽어 현재 비중과 목표 비중의 차이를 계산
- 리밸런싱이 필요한 자산에 대해 매매 주문을 생성
"""
import yaml
from dataclasses import dataclass
from typing import Optional


@dataclass
class TradeOrder:
    """매매 주문 데이터."""
    ticker: str
    code: str
    action: str          # "BUY" or "SELL"
    amount_krw: int      # 매매 금액 (원)
    reason: str          # 리밸런싱 사유

    def __str__(self):
        return (
            f"[{self.action}] {self.ticker} ({self.code}) "
            f"₩{self.amount_krw:,} - {self.reason}"
        )


def load_config(path: str = "portfolio_config.yaml") -> dict:
    """포트폴리오 설정 파일을 로드한다."""
    with open(path, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)


def get_current_holdings(broker_api) -> dict:
    """증권사 API에서 현재 보유 자산 정보를 조회한다.

    Returns:
        {종목코드: {"value_krw": 금액, "quantity": 수량}}
    """
    # 실제 구현 시 한국투자증권 OpenAPI, 키움 Open API+ 등 사용
    # 여기서는 예시 데이터 반환
    return {
        "360750": {"value_krw": 4_200_000, "quantity": 250},
        "069500": {"value_krw": 1_600_000, "quantity": 45},
        "305080": {"value_krw": 1_400_000, "quantity": 140},
        "443160": {"value_krw": 500_000, "quantity": 50},
        "091230": {"value_krw": 1_500_000, "quantity": 30},
        "305720": {"value_krw": 800_000, "quantity": 25},
        "319640": {"value_krw": 1_000_000, "quantity": 80},
    }


def calculate_drift(config: dict, holdings: dict) -> list[dict]:
    """각 자산의 목표 비중 대비 현재 편차를 계산한다."""
    total_value = sum(h["value_krw"] for h in holdings.values())
    if total_value == 0:
        return []

    results = []
    for section in ["core", "satellite"]:
        for asset in config["portfolio"]["allocation"][section]["assets"]:
            code = asset["code"]
            holding = holdings.get(code, {"value_krw": 0})
            current_weight = holding["value_krw"] / total_value
            target_weight = asset["target"]
            drift = current_weight - target_weight

            results.append({
                "ticker": asset["ticker"],
                "code": code,
                "target_weight": target_weight,
                "current_weight": round(current_weight, 4),
                "drift": round(drift, 4),
                "drift_pct": round(drift * 100, 2),
                "value_krw": holding["value_krw"],
                "section": section,
            })

    return results


def needs_rebalancing(
    drift_data: list[dict],
    threshold: float = 0.05,
) -> bool:
    """편차 임계치를 초과한 자산이 있는지 확인한다."""
    return any(abs(d["drift"]) > threshold for d in drift_data)


def generate_orders(
    config: dict,
    drift_data: list[dict],
    total_value: int,
    min_trade: int = 100_000,
) -> list[TradeOrder]:
    """리밸런싱에 필요한 매매 주문 목록을 생성한다."""
    orders = []

    for asset in drift_data:
        target_value = int(total_value * asset["target_weight"])
        diff = target_value - asset["value_krw"]

        # 최소 거래 금액 미만이면 스킵
        if abs(diff) < min_trade:
            continue

        action = "BUY" if diff > 0 else "SELL"
        reason = (
            f"목표 {asset['target_weight']*100:.1f}% vs "
            f"현재 {asset['current_weight']*100:.1f}% "
            f"(편차 {asset['drift_pct']:+.2f}%p)"
        )

        orders.append(TradeOrder(
            ticker=asset["ticker"],
            code=asset["code"],
            action=action,
            amount_krw=abs(diff),
            reason=reason,
        ))

    return orders


def run_rebalancing(
    config_path: str = "portfolio_config.yaml",
    dry_run: bool = True,
) -> None:
    """리밸런싱 전체 프로세스를 실행한다."""
    config = load_config(config_path)
    holdings = get_current_holdings(broker_api=None)
    total_value = sum(h["value_krw"] for h in holdings.values())

    print(f"총 자산: ₩{total_value:,}")
    print(f"리밸런싱 전략: {config['portfolio']['rebalancing']['strategy']}")
    print()

    # 편차 계산
    drift_data = calculate_drift(config, holdings)

    print("=== 현재 포트폴리오 편차 ===")
    for d in drift_data:
        flag = " ⚠" if abs(d["drift"]) > 0.05 else ""
        print(
            f"  {d['ticker']}: "
            f"목표 {d['target_weight']*100:.1f}% | "
            f"현재 {d['current_weight']*100:.1f}% | "
            f"편차 {d['drift_pct']:+.2f}%p{flag}"
        )
    print()

    # 리밸런싱 필요 여부 판단
    threshold = config["portfolio"]["rebalancing"]["drift_trigger"]["threshold"]
    if not needs_rebalancing(drift_data, threshold):
        print("리밸런싱 불필요: 모든 자산이 임계치 내에 있음")
        return

    # 매매 주문 생성
    min_trade = config["portfolio"]["rebalancing"]["constraints"]["min_trade_amount"]
    orders = generate_orders(config, drift_data, total_value, min_trade)

    print(f"=== 매매 주문 ({len(orders)}건) ===")
    for order in orders:
        print(f"  {order}")
    print()

    # 비용 추정
    commission_rate = config["portfolio"]["costs"]["etf_commission_rate"]
    total_trade = sum(o.amount_krw for o in orders)
    estimated_cost = int(total_trade * commission_rate)
    print(f"총 매매 금액: ₩{total_trade:,}")
    print(f"예상 수수료: ₩{estimated_cost:,}")

    if dry_run:
        print("\n[DRY RUN] 실제 주문은 실행되지 않았습니다.")
    else:
        print("\n[LIVE] 주문을 실행합니다...")
        # 실제 구현 시 증권사 API 호출


if __name__ == "__main__":
    run_rebalancing(dry_run=True)

실행 결과 예시

총 자산:11,000,000
리밸런싱 전략: hybrid

=== 현재 포트폴리오 편차 ===
  TIGER 미국S&P500: 목표 35.0% | 현재 38.2% | 편차 +3.18%p
  KODEX 200: 목표 15.0% | 현재 14.5% | 편차 -0.45%p
  TIGER 미국채10년선물: 목표 15.0% | 현재 12.7% | 편차 -2.27%p
  KODEX 종합채권(AA-이상)액티브: 목표 5.0% | 현재 4.5% | 편차 -0.45%p
  TIGER 반도체: 목표 10.0% | 현재 13.6% | 편차 +3.64%p
  KODEX 2차전지산업: 목표 10.0% | 현재 7.3% | 편차 -2.73%p
  TIGER 금은선물(H): 목표 10.0% | 현재 9.1% | 편차 -0.91%p

=== 매매 주문 (4) ===
  [SELL] TIGER 미국S&P500 (360750)350,000 - 목표 35.0% vs 현재 38.2% (편차 +3.18%p)
  [BUY] TIGER 미국채10년선물 (305080)250,000 - 목표 15.0% vs 현재 12.7% (편차 -2.27%p)
  [SELL] TIGER 반도체 (091230)400,000 - 목표 10.0% vs 현재 13.6% (편차 +3.64%p)
  [BUY] KODEX 2차전지산업 (305720)300,000 - 목표 10.0% vs 현재 7.3% (편차 -2.73%p)

총 매매 금액:1,300,000
예상 수수료:195

[DRY RUN] 실제 주문은 실행되지 않았습니다.

자동 실행 스케줄링

GitHub Actions로 주간 점검 자동화

# .github/workflows/rebalance-check.yml
name: ETF 리밸런싱 점검

on:
  schedule:
    # 매주 월요일 한국시간 09:00 (UTC 00:00)
    - cron: '0 0 * * 1'
  workflow_dispatch: # 수동 실행 가능

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Python 환경 설정
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: 의존성 설치
        run: pip install pyyaml requests

      - name: 리밸런싱 점검 (Dry Run)
        run: python rebalance.py --dry-run
        env:
          BROKER_APP_KEY: ${{ secrets.BROKER_APP_KEY }}
          BROKER_APP_SECRET: ${{ secrets.BROKER_APP_SECRET }}

      - name: 결과 알림 (Slack)
        if: always()
        run: |
          python notify.py --channel "#investment" \
            --message "주간 리밸런싱 점검 완료. 상세 내용은 GitHub Actions 로그 참조."
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Slack 알림 스크립트

"""리밸런싱 점검 결과를 Slack으로 전송한다."""
import json
import os
import urllib.request


def send_slack_notification(
    webhook_url: str,
    channel: str,
    message: str,
    orders: list[dict] | None = None,
) -> None:
    """Slack Incoming Webhook으로 알림을 전송한다."""
    blocks = [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": "ETF 리밸런싱 점검 결과",
            },
        },
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": message,
            },
        },
    ]

    if orders:
        order_text = "\n".join(
            f"- {'매수' if o['action'] == 'BUY' else '매도'} "
            f"{o['ticker']}{o['amount']:,}"
            for o in orders
        )
        blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f"*필요 매매 주문:*\n{order_text}",
            },
        })

    payload = json.dumps({
        "channel": channel,
        "blocks": blocks,
    }).encode("utf-8")

    req = urllib.request.Request(
        webhook_url,
        data=payload,
        headers={"Content-Type": "application/json"},
    )
    urllib.request.urlopen(req)


if __name__ == "__main__":
    send_slack_notification(
        webhook_url=os.environ["SLACK_WEBHOOK_URL"],
        channel="#investment",
        message="모든 자산이 목표 비중 ±5% 이내입니다. 리밸런싱 불필요.",
    )

거래 로그와 성과 추적

리밸런싱을 실행한 후에는 반드시 기록을 남긴다. 나중에 "리밸런싱이 효과가 있었는가?"를 검증하려면 데이터가 필요하다.

거래 로그 스키마

"""리밸런싱 거래 로그를 JSON 파일로 관리한다."""
import json
from datetime import datetime
from dataclasses import dataclass, asdict


@dataclass
class RebalanceLog:
    """리밸런싱 실행 기록."""
    date: str
    trigger: str  # "scheduled" | "drift" | "manual"
    total_portfolio_value: int
    trades: list[dict]
    total_trade_amount: int
    total_commission: int
    pre_drift: dict   # 리밸런싱 전 편차
    post_drift: dict  # 리밸런싱 후 편차
    notes: str = ""


def save_log(log: RebalanceLog, path: str = "rebalance_log.json"):
    """거래 로그를 파일에 추가한다."""
    try:
        with open(path, "r") as f:
            logs = json.load(f)
    except FileNotFoundError:
        logs = []

    logs.append(asdict(log))

    with open(path, "w", encoding="utf-8") as f:
        json.dump(logs, f, indent=2, ensure_ascii=False)


# 사용 예시
log = RebalanceLog(
    date="2026-03-04",
    trigger="scheduled",
    total_portfolio_value=11_000_000,
    trades=[
        {"ticker": "TIGER 미국S&P500", "action": "SELL", "amount": 350_000},
        {"ticker": "TIGER 미국채10년선물", "action": "BUY", "amount": 250_000},
    ],
    total_trade_amount=600_000,
    total_commission=90,
    pre_drift={"TIGER 미국S&P500": 3.18, "TIGER 미국채10년선물": -2.27},
    post_drift={"TIGER 미국S&P500": 0.12, "TIGER 미국채10년선물": -0.08},
    notes="2026년 Q1 정기 리밸런싱. S&P500 비중 과다로 채권으로 이전.",
)
save_log(log)

분기 성과 리뷰 체크리스트

분기마다 아래 항목을 점검한다.

  • 리밸런싱 실행 횟수와 사유 확인 (정기 vs 긴급)
  • 총 매매 비용(수수료 + 슬리피지) 확인
  • 리밸런싱 전후 편차 변화 확인
  • 포트폴리오 수익률 vs 벤치마크(KOSPI, S&P500) 비교
  • 목표 비중 자체의 적정성 검토 (시장 환경 변화 반영)
  • 매매 규칙 변경이 필요한 경우 YAML 파일 수정 + Git 커밋

한국 투자자를 위한 실전 고려 사항

세금과 수수료

항목국내 ETF해외 ETF (직접투자)
매매 수수료0.015% 내외 (온라인)0.1-0.25%
매매차익 과세비과세 (2025 기준)양도소득세 22% (250만원 공제)
배당소득세15.4%15% (미국 원천징수)
환전 비용없음환전 스프레드 0.1-0.5%

리밸런싱 자동화 시 국내 ETF 위주로 포트폴리오를 구성하면 매매차익 비과세 혜택으로 비용이 크게 줄어든다. 해외 ETF를 직접 보유한 경우, 연간 250만원 양도소득 공제 한도를 고려해 매매 타이밍을 조절할 수 있다.

증권사 API 현황 (2026년 기준)

증권사API 이름특징
한국투자증권KIS Developers Open APIREST API, 가장 활발한 커뮤니티
키움증권Open API+윈도우 전용, COM 기반
미래에셋증권m.Stock Open APIREST API
NH투자증권QV Open APIREST API

Python으로 자동 매매를 구현하려면 한국투자증권 KIS Developers가 REST API 기반으로 가장 접근성이 좋다. pykis 라이브러리를 활용하면 인증부터 주문까지 Python으로 처리할 수 있다.

배당 재투자와 현금 흐름 처리

리밸런싱 전에 반드시 확인해야 할 것:

  1. 배당금 입금: 배당이 현금으로 쌓여 있으면 리밸런싱 계산 시 현금 비중이 과대 계상된다. 배당금을 먼저 재투자하거나, 리밸런싱 금액에서 제외한다.
  2. 정기 입금: 매월 적립식으로 추가 입금하는 경우, 부족 비중 자산에 우선 배분하면 매도 없이 리밸런싱이 가능하다 (Cash Flow Rebalancing).
  3. 배당 기준일: ETF 배당 기준일 전후 1주일은 가격 변동이 크므로 리밸런싱을 피하는 것이 좋다.

리밸런싱 자동화 시 주의할 실수

실수 1: 백테스트 결과만 보고 전략을 정함

백테스트에서 매년 리밸런싱이 최적이었다고 해서 실전에서도 그런 것은 아니다. 백테스트에는 슬리피지, 호가 스프레드, 체결 실패가 반영되지 않는다. 반드시 dry-run으로 실제 체결 환경을 시뮬레이션해야 한다.

실수 2: 소액 거래까지 전부 실행

편차가 0.3%인 자산까지 리밸런싱하면 수수료만 나간다. 최소 거래 금액(예: 10만원)을 설정하고, 그 이하는 무시한다.

실수 3: 규칙을 감정적으로 변경

"이번엔 반도체가 더 오를 것 같으니 비중을 15%로 올리자"는 리밸런싱이 아니라 투기다. 규칙 변경은 반드시 YAML 파일에 기록하고, Git 커밋 메시지에 사유를 남긴다. 최소 1주일의 쿨다운 기간을 두고 감정이 아닌 데이터로 판단한다.

실수 4: 세금 영향을 무시

해외 ETF를 보유한 경우, 리밸런싱으로 발생하는 양도차익이 연간 250만원을 넘으면 22%의 세금이 발생한다. 연말에 한꺼번에 리밸런싱하면 세금 폭탄을 맞을 수 있다. 분기별로 나눠 실행하거나, 매수 위주로 리밸런싱(Cash Flow Rebalancing)하는 것이 유리하다.

퀴즈

Q1. 리밸런싱 자동화의 핵심 목적은 수익 극대화인가? 정답: ||아니다. 핵심 목적은 의사결정에서 감정을 제거하고 규칙을 일관되게 실행하는 것이다. Vanguard 연구에 따르면 리밸런싱의 가장 큰 효과는 위험(표준편차)을 설계 수준으로 유지하는 것이다.||

Q2. 하이브리드 리밸런싱 방식에서 "긴급 리밸런싱"이 발동되는 조건은? 정답: ||어떤 자산이든 목표 비중 대비 ±5%(또는 설정한 임계치) 이상 이탈하면 정기 점검일을 기다리지 않고 즉시 리밸런싱을 실행한다.||

Q3. Cash Flow Rebalancing이란 무엇이며 장점은? 정답: ||매월 적립식으로 추가 입금하는 금액을 부족 비중 자산에 우선 배분하는 방식이다. 기존 보유 자산을 매도하지 않으므로 수수료와 세금이 발생하지 않는다.||

Q4. 백테스트 결과만으로 리밸런싱 전략을 확정하면 안 되는 이유는? 정답: ||백테스트에는 슬리피지, 호가 스프레드, 체결 실패, 실시간 가격 변동이 반영되지 않는다. 반드시 dry-run으로 실제 체결 환경을 시뮬레이션해야 한다.||

Q5. 리밸런싱 규칙을 YAML 파일 + Git으로 관리하는 이유는? 정답: ||규칙 변경의 이력을 추적하고, 감정적 변경을 방지하기 위해서다. 커밋 메시지에 변경 사유를 남기면 나중에 "왜 이 비중으로 바꿨는지" 확인할 수 있다.||

Q6. 국내 ETF 위주로 포트폴리오를 구성하면 리밸런싱 비용이 줄어드는 이유는? 정답: ||국내 ETF는 매매차익이 비과세(2025년 기준)이므로 리밸런싱으로 인한 세금 부담이 없다. 해외 ETF는 양도소득세 22%(250만원 공제 후)가 발생해 빈번한 리밸런싱 시 비용이 커진다.||

참고 자료

ETF Rebalancing Automation Guide: Rule-Based Investment Operations

ETF Rebalancing Automation Guide: Rule-Based Investment Operations

Why You Need to Automate Rebalancing

Everyone can build a portfolio and make purchases. The problem comes afterward. After six months, stocks may have risen to 80% of the portfolio while bonds have shrunk to 10%. Once emotions like "I think it'll go up a bit more" creep in, rebalancing gets postponed forever.

According to Vanguard's 2023 study The Case for Rebalancing, portfolios that were never rebalanced had 30-50% higher risk (standard deviation) than the original design after 10 years. The issue isn't returns -- it's the collapse of risk management.

The purpose of automation is not to maximize returns. The core objective is to remove emotion from decision-making and execute rules consistently. As William Bernstein emphasizes in The Intelligent Asset Allocator, "Rebalancing is the mechanical repetition of buying low and selling high."

Rebalancing Methods Compared: Time-Based vs Threshold-Based vs Hybrid

Comparison of Three Methods

MethodTriggerProsConsBest For
Time-BasedFixed dates (quarterly/semi-annual/annual)Simple, predictableSlow response to market volatilitySmall portfolios, beginners
Threshold-BasedWhen deviation exceeds +/-N% from targetResponsive to market changesFrequent trades increase costsHolding volatile assets
HybridQuarterly check + immediate if over 5%Balances cost and riskMore complex rulesBest for practice, recommended

Hybrid Method in Detail

The hybrid method applies two rules simultaneously:

  1. Scheduled check: Review all portfolio weights on the first business day of each quarter
  2. Emergency rebalancing: If any asset deviates +/-5% or more from its target weight, rebalance immediately

Example: In a portfolio targeting stocks 60% / bonds 30% / gold 10%, if stocks exceed 66% or drop below 54%, rebalance immediately without waiting for the quarterly check.

Portfolio Configuration File Design

The first step in rebalancing automation is documenting your investment rules as code. Managing with a YAML file lets you track changes using Git.

# portfolio_config.yaml
# Last modified: 2026-03-04
# Change reason: 2026 asset allocation adjustment (increased bond allocation)

portfolio:
  name: '2026 Long-Term Growth'
  owner: 'youngjukim'
  base_currency: 'KRW'
  broker: 'Korea Investment & Securities' # or Kiwoom, Mirae Asset, etc.

  # Target asset allocation
  allocation:
    core:
      weight: 0.70
      assets:
        - ticker: 'TIGER US S&P500'
          code: '360750'
          target: 0.35
          min: 0.30
          max: 0.40
        - ticker: 'KODEX 200'
          code: '069500'
          target: 0.15
          min: 0.10
          max: 0.20
        - ticker: 'TIGER US Treasury 10Y Futures'
          code: '305080'
          target: 0.15
          min: 0.10
          max: 0.20
        - ticker: 'KODEX Composite Bond (AA- and above) Active'
          code: '443160'
          target: 0.05
          min: 0.02
          max: 0.08

    satellite:
      weight: 0.30
      assets:
        - ticker: 'TIGER Semiconductor'
          code: '091230'
          target: 0.10
          min: 0.05
          max: 0.15
        - ticker: 'KODEX Secondary Battery Industry'
          code: '305720'
          target: 0.10
          min: 0.05
          max: 0.15
        - ticker: 'TIGER Gold & Silver Futures (H)'
          code: '319640'
          target: 0.10
          min: 0.05
          max: 0.15

  # Rebalancing rules
  rebalancing:
    strategy: 'hybrid'
    scheduled:
      frequency: 'quarterly'
      months: [1, 4, 7, 10] # First business day of Jan, Apr, Jul, Oct
      day: 'first_business_day'
    drift_trigger:
      threshold: 0.05 # Emergency rebalancing when +/-5% deviation
      check_frequency: 'weekly' # Check every Monday
    constraints:
      min_trade_amount: 100000 # Minimum trade amount 100,000 KRW
      avoid_ex_dividend_dates: true # Avoid trading around ex-dividend dates
      tax_loss_harvesting: false # Low effectiveness in Korea

  # Cost settings
  costs:
    etf_commission_rate: 0.00015 # 0.015% (online broker standard)
    etf_tax_rate: 0.0 # Domestic ETF capital gains tax-free (as of 2025)
    overseas_etf_tax_rate: 0.22 # Overseas ETF capital gains tax 22%
    slippage_estimate: 0.001 # 0.1% slippage estimate

Rebalancing Engine Implementation (Python)

Core Logic: Check Current Weights, Calculate Drift, Generate Trade Orders

"""
ETF Rebalancing Engine
- Reads portfolio config file and calculates the difference between current and target weights
- Generates trade orders for assets that need rebalancing
"""
import yaml
from dataclasses import dataclass
from typing import Optional


@dataclass
class TradeOrder:
    """Trade order data."""
    ticker: str
    code: str
    action: str          # "BUY" or "SELL"
    amount_krw: int      # Trade amount (KRW)
    reason: str          # Rebalancing reason

    def __str__(self):
        return (
            f"[{self.action}] {self.ticker} ({self.code}) "
            f"₩{self.amount_krw:,} - {self.reason}"
        )


def load_config(path: str = "portfolio_config.yaml") -> dict:
    """Load portfolio configuration file."""
    with open(path, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)


def get_current_holdings(broker_api) -> dict:
    """Fetch current holdings from broker API.

    Returns:
        {asset_code: {"value_krw": amount, "quantity": count}}
    """
    # In production, use Korea Investment OpenAPI, Kiwoom Open API+, etc.
    # Returning sample data here
    return {
        "360750": {"value_krw": 4_200_000, "quantity": 250},
        "069500": {"value_krw": 1_600_000, "quantity": 45},
        "305080": {"value_krw": 1_400_000, "quantity": 140},
        "443160": {"value_krw": 500_000, "quantity": 50},
        "091230": {"value_krw": 1_500_000, "quantity": 30},
        "305720": {"value_krw": 800_000, "quantity": 25},
        "319640": {"value_krw": 1_000_000, "quantity": 80},
    }


def calculate_drift(config: dict, holdings: dict) -> list[dict]:
    """Calculate the drift of each asset from its target weight."""
    total_value = sum(h["value_krw"] for h in holdings.values())
    if total_value == 0:
        return []

    results = []
    for section in ["core", "satellite"]:
        for asset in config["portfolio"]["allocation"][section]["assets"]:
            code = asset["code"]
            holding = holdings.get(code, {"value_krw": 0})
            current_weight = holding["value_krw"] / total_value
            target_weight = asset["target"]
            drift = current_weight - target_weight

            results.append({
                "ticker": asset["ticker"],
                "code": code,
                "target_weight": target_weight,
                "current_weight": round(current_weight, 4),
                "drift": round(drift, 4),
                "drift_pct": round(drift * 100, 2),
                "value_krw": holding["value_krw"],
                "section": section,
            })

    return results


def needs_rebalancing(
    drift_data: list[dict],
    threshold: float = 0.05,
) -> bool:
    """Check if any asset exceeds the drift threshold."""
    return any(abs(d["drift"]) > threshold for d in drift_data)


def generate_orders(
    config: dict,
    drift_data: list[dict],
    total_value: int,
    min_trade: int = 100_000,
) -> list[TradeOrder]:
    """Generate the list of trade orders needed for rebalancing."""
    orders = []

    for asset in drift_data:
        target_value = int(total_value * asset["target_weight"])
        diff = target_value - asset["value_krw"]

        # Skip if below minimum trade amount
        if abs(diff) < min_trade:
            continue

        action = "BUY" if diff > 0 else "SELL"
        reason = (
            f"Target {asset['target_weight']*100:.1f}% vs "
            f"Current {asset['current_weight']*100:.1f}% "
            f"(Drift {asset['drift_pct']:+.2f}%p)"
        )

        orders.append(TradeOrder(
            ticker=asset["ticker"],
            code=asset["code"],
            action=action,
            amount_krw=abs(diff),
            reason=reason,
        ))

    return orders


def run_rebalancing(
    config_path: str = "portfolio_config.yaml",
    dry_run: bool = True,
) -> None:
    """Execute the complete rebalancing process."""
    config = load_config(config_path)
    holdings = get_current_holdings(broker_api=None)
    total_value = sum(h["value_krw"] for h in holdings.values())

    print(f"Total assets: ₩{total_value:,}")
    print(f"Rebalancing strategy: {config['portfolio']['rebalancing']['strategy']}")
    print()

    # Calculate drift
    drift_data = calculate_drift(config, holdings)

    print("=== Current Portfolio Drift ===")
    for d in drift_data:
        flag = " ⚠" if abs(d["drift"]) > 0.05 else ""
        print(
            f"  {d['ticker']}: "
            f"Target {d['target_weight']*100:.1f}% | "
            f"Current {d['current_weight']*100:.1f}% | "
            f"Drift {d['drift_pct']:+.2f}%p{flag}"
        )
    print()

    # Determine if rebalancing is needed
    threshold = config["portfolio"]["rebalancing"]["drift_trigger"]["threshold"]
    if not needs_rebalancing(drift_data, threshold):
        print("Rebalancing not needed: All assets are within threshold")
        return

    # Generate trade orders
    min_trade = config["portfolio"]["rebalancing"]["constraints"]["min_trade_amount"]
    orders = generate_orders(config, drift_data, total_value, min_trade)

    print(f"=== Trade Orders ({len(orders)} orders) ===")
    for order in orders:
        print(f"  {order}")
    print()

    # Cost estimate
    commission_rate = config["portfolio"]["costs"]["etf_commission_rate"]
    total_trade = sum(o.amount_krw for o in orders)
    estimated_cost = int(total_trade * commission_rate)
    print(f"Total trade amount: ₩{total_trade:,}")
    print(f"Estimated commission: ₩{estimated_cost:,}")

    if dry_run:
        print("\n[DRY RUN] No actual orders were executed.")
    else:
        print("\n[LIVE] Executing orders...")
        # In production, call broker API


if __name__ == "__main__":
    run_rebalancing(dry_run=True)

Example Output

Total assets:11,000,000
Rebalancing strategy: hybrid

=== Current Portfolio Drift ===
  TIGER US S&P500: Target 35.0% | Current 38.2% | Drift +3.18%p
  KODEX 200: Target 15.0% | Current 14.5% | Drift -0.45%p
  TIGER US Treasury 10Y Futures: Target 15.0% | Current 12.7% | Drift -2.27%p
  KODEX Composite Bond (AA- and above) Active: Target 5.0% | Current 4.5% | Drift -0.45%p
  TIGER Semiconductor: Target 10.0% | Current 13.6% | Drift +3.64%p
  KODEX Secondary Battery Industry: Target 10.0% | Current 7.3% | Drift -2.73%p
  TIGER Gold & Silver Futures (H): Target 10.0% | Current 9.1% | Drift -0.91%p

=== Trade Orders (4 orders) ===
  [SELL] TIGER US S&P500 (360750)350,000 - Target 35.0% vs Current 38.2% (Drift +3.18%p)
  [BUY] TIGER US Treasury 10Y Futures (305080)250,000 - Target 15.0% vs Current 12.7% (Drift -2.27%p)
  [SELL] TIGER Semiconductor (091230)400,000 - Target 10.0% vs Current 13.6% (Drift +3.64%p)
  [BUY] KODEX Secondary Battery Industry (305720)300,000 - Target 10.0% vs Current 7.3% (Drift -2.73%p)

Total trade amount:1,300,000
Estimated commission:195

[DRY RUN] No actual orders were executed.

Automated Execution Scheduling

Automating Weekly Checks with GitHub Actions

# .github/workflows/rebalance-check.yml
name: ETF Rebalancing Check

on:
  schedule:
    # Every Monday at 09:00 KST (00:00 UTC)
    - cron: '0 0 * * 1'
  workflow_dispatch: # Manual execution

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: pip install pyyaml requests

      - name: Rebalancing check (Dry Run)
        run: python rebalance.py --dry-run
        env:
          BROKER_APP_KEY: ${{ secrets.BROKER_APP_KEY }}
          BROKER_APP_SECRET: ${{ secrets.BROKER_APP_SECRET }}

      - name: Send notification (Slack)
        if: always()
        run: |
          python notify.py --channel "#investment" \
            --message "Weekly rebalancing check complete. See GitHub Actions logs for details."
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Slack Notification Script

"""Send rebalancing check results to Slack."""
import json
import os
import urllib.request


def send_slack_notification(
    webhook_url: str,
    channel: str,
    message: str,
    orders: list[dict] | None = None,
) -> None:
    """Send notification via Slack Incoming Webhook."""
    blocks = [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": "ETF Rebalancing Check Results",
            },
        },
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": message,
            },
        },
    ]

    if orders:
        order_text = "\n".join(
            f"- {'Buy' if o['action'] == 'BUY' else 'Sell'} "
            f"{o['ticker']}{o['amount']:,}"
            for o in orders
        )
        blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f"*Required Trade Orders:*\n{order_text}",
            },
        })

    payload = json.dumps({
        "channel": channel,
        "blocks": blocks,
    }).encode("utf-8")

    req = urllib.request.Request(
        webhook_url,
        data=payload,
        headers={"Content-Type": "application/json"},
    )
    urllib.request.urlopen(req)


if __name__ == "__main__":
    send_slack_notification(
        webhook_url=os.environ["SLACK_WEBHOOK_URL"],
        channel="#investment",
        message="All assets are within +/-5% of target weights. No rebalancing needed.",
    )

Trade Logging and Performance Tracking

After executing a rebalance, you must always keep records. You need data to verify later whether rebalancing was effective.

Trade Log Schema

"""Manage rebalancing trade logs as JSON files."""
import json
from datetime import datetime
from dataclasses import dataclass, asdict


@dataclass
class RebalanceLog:
    """Rebalancing execution record."""
    date: str
    trigger: str  # "scheduled" | "drift" | "manual"
    total_portfolio_value: int
    trades: list[dict]
    total_trade_amount: int
    total_commission: int
    pre_drift: dict   # Drift before rebalancing
    post_drift: dict  # Drift after rebalancing
    notes: str = ""


def save_log(log: RebalanceLog, path: str = "rebalance_log.json"):
    """Append trade log to file."""
    try:
        with open(path, "r") as f:
            logs = json.load(f)
    except FileNotFoundError:
        logs = []

    logs.append(asdict(log))

    with open(path, "w", encoding="utf-8") as f:
        json.dump(logs, f, indent=2, ensure_ascii=False)


# Usage example
log = RebalanceLog(
    date="2026-03-04",
    trigger="scheduled",
    total_portfolio_value=11_000_000,
    trades=[
        {"ticker": "TIGER US S&P500", "action": "SELL", "amount": 350_000},
        {"ticker": "TIGER US Treasury 10Y Futures", "action": "BUY", "amount": 250_000},
    ],
    total_trade_amount=600_000,
    total_commission=90,
    pre_drift={"TIGER US S&P500": 3.18, "TIGER US Treasury 10Y Futures": -2.27},
    post_drift={"TIGER US S&P500": 0.12, "TIGER US Treasury 10Y Futures": -0.08},
    notes="2026 Q1 scheduled rebalancing. S&P500 overweight transferred to bonds.",
)
save_log(log)

Quarterly Performance Review Checklist

Review the following items every quarter:

  • Confirm the number of rebalancing executions and reasons (scheduled vs emergency)
  • Verify total trading costs (commissions + slippage)
  • Review drift changes before and after rebalancing
  • Compare portfolio returns vs benchmarks (KOSPI, S&P500)
  • Evaluate whether current target weights are still appropriate (reflecting market changes)
  • If trading rules need changing, modify the YAML file + Git commit

Practical Considerations for Korean Investors

Taxes and Fees

ItemDomestic ETFOverseas ETF (Direct Investment)
Trading commissionAround 0.015% (online)0.1-0.25%
Capital gains taxTax-free (as of 2025)22% capital gains tax (after 2.5M KRW deduction)
Dividend tax15.4%15% (US withholding)
FX conversion costNoneFX spread 0.1-0.5%

When automating rebalancing, building a portfolio primarily with domestic ETFs significantly reduces costs thanks to the capital gains tax exemption. If you hold overseas ETFs directly, you can time trades considering the annual 2.5 million KRW capital gains deduction limit.

Broker API Status (as of 2026)

BrokerAPI NameFeatures
Korea Investment & SecuritiesKIS Developers Open APIREST API, most active community
Kiwoom SecuritiesOpen API+Windows only, COM-based
Mirae Asset Securitiesm.Stock Open APIREST API
NH Investment & SecuritiesQV Open APIREST API

For implementing automated trading in Python, Korea Investment & Securities' KIS Developers is the most accessible with its REST API. Using the pykis library, you can handle everything from authentication to order placement in Python.

Dividend Reinvestment and Cash Flow Handling

Things to check before rebalancing:

  1. Dividend deposits: If dividends have accumulated as cash, the cash allocation will be overstated in rebalancing calculations. Either reinvest dividends first or exclude them from the rebalancing amount.
  2. Regular deposits: If you make monthly contributions, allocating them to underweight assets enables rebalancing without selling (Cash Flow Rebalancing).
  3. Ex-dividend dates: ETF prices tend to be volatile around ex-dividend dates, so it's best to avoid rebalancing during the one-week window around these dates.

Common Mistakes in Rebalancing Automation

Mistake 1: Setting Strategy Based Only on Backtesting Results

Just because annual rebalancing was optimal in a backtest doesn't mean it will work the same in practice. Backtests don't account for slippage, bid-ask spreads, or failed executions. Always simulate in a real execution environment using dry-run mode.

Mistake 2: Executing All Trades Including Small Amounts

If you rebalance assets with only 0.3% deviation, you're just paying commissions. Set a minimum trade amount (e.g., 100,000 KRW) and ignore anything below that.

Mistake 3: Changing Rules Emotionally

"Let's raise the semiconductor allocation to 15% because I think it'll go up more" is speculation, not rebalancing. Rule changes must be recorded in the YAML file with the reason noted in the Git commit message. Allow a minimum one-week cooldown period and make decisions based on data, not emotions.

Mistake 4: Ignoring Tax Impact

If you hold overseas ETFs, capital gains from rebalancing exceeding the annual 2.5 million KRW deduction are taxed at 22%. Doing all your rebalancing at year-end could result in a tax surprise. It's better to spread execution across quarters or focus on buy-side rebalancing (Cash Flow Rebalancing).

Quiz

Q1. Is the core purpose of rebalancing automation to maximize returns? Answer: No. The core purpose is to remove emotion from decision-making and execute rules consistently. According to Vanguard's research, the greatest benefit of rebalancing is maintaining risk (standard deviation) at the designed level.

Q2. What triggers "emergency rebalancing" in the hybrid method? Answer: When any asset deviates +/-5% (or the configured threshold) from its target weight, rebalancing is executed immediately without waiting for the scheduled check date.

Q3. What is Cash Flow Rebalancing and what are its advantages? Answer: It's a method of allocating monthly contributions to underweight assets. Since no existing holdings are sold, no commissions or taxes are incurred.

Q4. Why shouldn't you finalize a rebalancing strategy based solely on backtesting results?

Answer: Backtests don't account for slippage, bid-ask spreads, failed executions, or real-time price movements. You must simulate using dry-run mode in a real execution environment.

Q5. Why manage rebalancing rules with YAML files + Git? Answer: To track the history of rule changes and prevent emotional modifications. By leaving the reason in commit messages, you can later verify "why this allocation was changed."

Q6. Why does building a portfolio primarily with domestic ETFs reduce rebalancing costs?

Answer: Because domestic ETF capital gains are tax-free (as of 2025), there's no tax burden from rebalancing. Overseas ETFs incur 22% capital gains tax (after 2.5M KRW deduction), making frequent rebalancing costly.

References