Skip to content
Published on

ETF Rebalancing Automation Guide: Rule-Based Investment Operations

Authors
  • Name
    Twitter
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