Skip to content
Published on

ETF Investment Strategies for Developers — From Asset Allocation to Quant Backtesting

Authors
  • Name
    Twitter
ETF Investment Strategies

Introduction

Developers are busy. There is no time to stare at stock charts all day or the bandwidth to analyze individual stocks. But money needs to work. ETFs (Exchange Traded Funds) are the optimal tool for diversified investing without individual stock analysis, and when combined with asset allocation strategies, they enable stable returns with minimal time commitment.

In this article, we approach ETF investment strategies the developer way — with data and code.

ETF Basics

ETF vs Individual Stocks vs Funds

| Feature         | Individual Stocks | Active Funds     | ETF (Passive)     |
|----------------|-------------------|------------------|-------------------|
| Diversification | No (manual)       | Yes (fund mgr)   | Yes (index track) |
| Fees (cost)     | None              | 1-2%/year        | 0.03-0.5%/year    |
| Trading         | Real-time         | Once per day      | Real-time         |
| Transparency    | High              | Low               | High              |
| Time required   | A lot             | Little            | Very little       |

Core ETF Universe

# Core ETFs for global asset allocation
CORE_ETFS = {
    # Equities
    "VTI": "US Total Stock Market",
    "VXUS": "Non-US Developed + Emerging Markets",
    "VWO": "Emerging Markets",
    "QQQ": "NASDAQ 100 (Tech Stocks)",

    # Bonds
    "BND": "US Aggregate Bonds",
    "TLT": "US Long-Term Treasury (20+ years)",
    "IEF": "US Intermediate Treasury (7-10 years)",
    "TIP": "Treasury Inflation-Protected Securities (TIPS)",

    # Alternatives
    "GLD": "Gold",
    "VNQ": "US REITs (Real Estate)",
    "DBC": "Commodities",

    # Korea
    "KODEX200": "KOSPI 200",
    "TIGER US S&P500": "S&P 500 (KRW denominated)",
    "ACE US NASDAQ100": "NASDAQ 100 (KRW denominated)",
}

Asset Allocation Strategies

1. All Weather Portfolio (Ray Dalio)

ALL_WEATHER = {
    "VTI": 0.30,   # US Stocks 30%
    "TLT": 0.40,   # Long-Term Treasury 40%
    "IEF": 0.15,   # Intermediate Treasury 15%
    "GLD": 0.075,  # Gold 7.5%
    "DBC": 0.075,  # Commodities 7.5%
}
# Feature: Prepared for all 4 economic seasons (Growth/Recession x Inflation/Deflation)
# Average annual return: ~7% (2005-2025)
# Maximum drawdown: ~12%

2. Permanent Portfolio (Harry Browne)

PERMANENT = {
    "VTI": 0.25,   # Stocks 25% (Growth)
    "TLT": 0.25,   # Long-Term Treasury 25% (Recession)
    "GLD": 0.25,   # Gold 25% (Inflation)
    "BIL": 0.25,   # Short-Term Treasury 25% (Safe Asset)
}
# Feature: Extremely simple, 25% always shines in any economic condition
# Average annual return: ~6%
# Maximum drawdown: ~10%

3. 60/40 Portfolio (Traditional)

CLASSIC_60_40 = {
    "VTI": 0.60,   # Stocks 60%
    "BND": 0.40,   # Bonds 40%
}
# Feature: The most traditional and simple
# Average annual return: ~8%
# Maximum drawdown: ~20%

4. Core-Satellite Strategy

CORE_SATELLITE = {
    # Core (80%) — Passive, low-cost
    "VTI": 0.50,    # US Total Market
    "VXUS": 0.20,   # International Stocks
    "BND": 0.10,    # Bonds

    # Satellite (20%) — Alpha seeking
    "QQQ": 0.10,    # Tech Growth
    "VNQ": 0.05,    # REITs
    "GLD": 0.05,    # Gold
}
# Feature: Capture market returns (beta) + seek some alpha

Python Backtesting

Data Collection

import yfinance as yf
import pandas as pd
import numpy as np

def get_etf_data(tickers: list, start: str = "2010-01-01") -> pd.DataFrame:
    """Download ETF price data"""
    data = yf.download(tickers, start=start, auto_adjust=True)
    prices = data["Close"]
    returns = prices.pct_change().dropna()
    return prices, returns


# Download data
tickers = ["VTI", "TLT", "IEF", "GLD", "DBC", "BND"]
prices, returns = get_etf_data(tickers)

Portfolio Backtest

def backtest_portfolio(
    returns: pd.DataFrame,
    weights: dict,
    rebalance_freq: str = "Q",  # Q=Quarterly, M=Monthly, Y=Yearly
    initial_capital: float = 10000
) -> pd.DataFrame:
    """Portfolio backtest"""
    tickers = list(weights.keys())
    w = np.array([weights[t] for t in tickers])

    # Daily portfolio returns
    port_returns = (returns[tickers] * w).sum(axis=1)

    # Rebalancing simulation
    if rebalance_freq:
        rebalance_dates = returns.resample(rebalance_freq).last().index

    # Cumulative returns
    cumulative = (1 + port_returns).cumprod() * initial_capital

    # Performance metrics
    total_return = cumulative.iloc[-1] / initial_capital - 1
    annual_return = (1 + total_return) ** (252 / len(port_returns)) - 1
    annual_vol = port_returns.std() * np.sqrt(252)
    sharpe = annual_return / annual_vol
    max_dd = (cumulative / cumulative.cummax() - 1).min()

    stats = {
        "Total Return": f"{total_return:.1%}",
        "CAGR": f"{annual_return:.1%}",
        "Annual Volatility": f"{annual_vol:.1%}",
        "Sharpe Ratio": f"{sharpe:.2f}",
        "Maximum Drawdown (MDD)": f"{max_dd:.1%}",
    }

    return cumulative, stats


# Backtest each strategy
strategies = {
    "All Weather": ALL_WEATHER,
    "Permanent Portfolio": PERMANENT,
    "60/40": CLASSIC_60_40,
}

for name, weights in strategies.items():
    cumulative, stats = backtest_portfolio(returns, weights)
    print(f"\n{'='*40}")
    print(f"  {name}")
    print(f"{'='*40}")
    for k, v in stats.items():
        print(f"  {k}: {v}")

Rebalancing Simulation

def simulate_rebalancing(
    prices: pd.DataFrame,
    weights: dict,
    initial_capital: float = 10000,
    rebalance_freq: str = "Q"
) -> pd.DataFrame:
    """Actual rebalancing trade simulation"""
    tickers = list(weights.keys())
    target_weights = np.array([weights[t] for t in tickers])

    # Initial positions
    shares = {}
    for ticker, w in weights.items():
        price = prices[ticker].iloc[0]
        shares[ticker] = (initial_capital * w) / price

    portfolio_value = []
    rebalance_log = []

    rebalance_dates = prices.resample(rebalance_freq).last().index

    for date in prices.index:
        # Current portfolio value
        current_value = sum(
            shares[t] * prices[t].loc[date]
            for t in tickers
        )
        portfolio_value.append(current_value)

        # Is it a rebalancing date?
        if date in rebalance_dates:
            current_weights = np.array([
                shares[t] * prices[t].loc[date] / current_value
                for t in tickers
            ])

            drift = np.abs(current_weights - target_weights).max()

            if drift > 0.05:  # Rebalance only when drift exceeds 5%
                for ticker, tw in weights.items():
                    target_value = current_value * tw
                    shares[ticker] = target_value / prices[ticker].loc[date]

                rebalance_log.append({
                    "date": date,
                    "value": current_value,
                    "drift": drift,
                })

    return pd.Series(portfolio_value, index=prices.index), rebalance_log

Dollar Cost Averaging (DCA)

def dollar_cost_averaging(
    prices: pd.DataFrame,
    ticker: str,
    monthly_amount: float = 500000,  # 500,000 KRW per month
    start_date: str = "2015-01-01"
) -> dict:
    """Dollar cost averaging simulation"""
    monthly_prices = prices[ticker].resample("MS").first()
    monthly_prices = monthly_prices[start_date:]

    total_invested = 0
    total_shares = 0

    for date, price in monthly_prices.items():
        shares_bought = monthly_amount / price
        total_shares += shares_bought
        total_invested += monthly_amount

    final_value = total_shares * prices[ticker].iloc[-1]
    total_return = (final_value / total_invested - 1) * 100
    avg_cost = total_invested / total_shares

    return {
        "Total Invested": f"{total_invested:,.0f} KRW",
        "Current Value": f"{final_value:,.0f} KRW",
        "Return": f"{total_return:.1f}%",
        "Average Cost Basis": f"{avg_cost:,.0f} KRW",
        "Current Price": f"{prices[ticker].iloc[-1]:,.0f} KRW",
        "Investment Period": f"{len(monthly_prices)} months",
    }

Tax and Cost Considerations

# Korean ETF tax structure
TAX_RULES = {
    "Domestic Stock ETF": {
        "Capital Gains": "Tax-free",
        "Distributions": "15.4% dividend income tax",
        "Examples": "KODEX200, TIGER KRX300",
    },
    "Domestic Other ETF": {
        "Capital Gains": "15.4% dividend income tax (holding period basis)",
        "Distributions": "15.4% dividend income tax",
        "Examples": "KODEX Gold, TIGER US S&P500",
    },
    "Direct Overseas Investment": {
        "Capital Gains": "22% capital gains tax (after 2.5 million KRW exemption)",
        "Distributions": "15% withholding tax",
        "Examples": "VTI, QQQ, TLT (direct US purchase)",
    },
}

def calculate_tax(profit: float, invest_type: str) -> float:
    """After-tax profit calculation"""
    if invest_type == "domestic_stock":
        return profit  # Tax-free
    elif invest_type == "domestic_other":
        return profit * (1 - 0.154)
    elif invest_type == "overseas_direct":
        taxable = max(0, profit - 2500000)  # 2.5 million KRW exemption
        tax = taxable * 0.22
        return profit - tax

Automatic Rebalancing Alerts

import yfinance as yf
from datetime import datetime

def check_rebalance_needed(
    portfolio: dict,
    target_weights: dict,
    threshold: float = 0.05
) -> list:
    """Check if rebalancing is needed"""
    # Get current prices
    tickers = list(portfolio.keys())
    current_prices = {}
    for t in tickers:
        data = yf.Ticker(t)
        current_prices[t] = data.info.get("regularMarketPrice", 0)

    # Current portfolio value
    total_value = sum(
        portfolio[t]["shares"] * current_prices[t]
        for t in tickers
    )

    # Drift check
    alerts = []
    for ticker in tickers:
        current_value = portfolio[ticker]["shares"] * current_prices[ticker]
        current_weight = current_value / total_value
        target_weight = target_weights[ticker]
        drift = abs(current_weight - target_weight)

        if drift > threshold:
            action = "Buy" if current_weight < target_weight else "Sell"
            amount = abs(current_weight - target_weight) * total_value

            alerts.append({
                "ticker": ticker,
                "Current Weight": f"{current_weight:.1%}",
                "Target Weight": f"{target_weight:.1%}",
                "Drift": f"{drift:.1%}",
                "Action": f"{action} {amount:,.0f} KRW",
            })

    return alerts


# Quarterly check (run as cron job)
alerts = check_rebalance_needed(my_portfolio, ALL_WEATHER)
if alerts:
    print("Warning: Rebalancing needed!")
    for a in alerts:
        print(f"  {a['ticker']}: {a['Current Weight']} -> {a['Target Weight']} ({a['Action']})")

Investment Principles for Developers

1. Automate
   - Monthly auto-transfer -> auto-purchase
   - Automate rebalancing alerts
   - Leave no room for emotions to interfere

2. Decide with data
   - Never use a strategy without backtesting
   - Past returns are not equal to future returns, but useful for risk measurement

3. Save time
   - Portfolio check: once per quarter is enough
   - Time spent on news/charts -> redirect to coding/learning

4. Reduce costs
   - Choose ETFs with expense ratios under 0.1%
   - Frequent trading = more taxes + more fees

5. Invest long-term
   - Minimum 10+ year investment horizon
   - Do not try to time the market
   - "Time in the market > Timing the market"

Quiz

Q1. What is the core idea of the All Weather portfolio?

It holds assets that perform well in each of the 4 economic seasons (Growth/Recession x Inflation/Deflation), pursuing stable returns regardless of the economic environment.

Q2. What is the purpose of rebalancing?

It restores a portfolio that has drifted from target weights back to its original allocation. It naturally creates a "sell high, buy low" effect.

Q3. What are the advantages of DCA (Dollar Cost Averaging)?

By investing a fixed amount regularly, it averages the purchase price. It eliminates the need to time the market and prevents emotional investing.

Q4. How is capital gains tax calculated for direct overseas ETF investment?

22% is taxed on gains after deducting 2.5 million KRW. Example: 10 million KRW profit results in (10M - 2.5M) x 0.22 = 1.65 million KRW tax.

Q5. What is the Sharpe Ratio?

(Return - Risk-free return) / Volatility, measuring excess return per unit of risk. Higher is more efficient. Generally, 1.0 or above is considered good.

Q6. What is the Core-Satellite strategy?

A strategy where the majority of assets (80%) are in low-cost index ETFs (core) to capture market returns, while a portion (20%) is in theme/sector ETFs (satellite) to seek alpha (excess returns).

Q7. What is MDD (Maximum Drawdown)?

The maximum drawdown is the largest peak-to-trough decline. It shows the worst-case scenario for a portfolio and serves as a measure of psychological resilience.

Conclusion

For developers, the appeal of ETF investing is its systematizability. Set a strategy, configure auto-purchases, and just receive quarterly rebalancing alerts. Invest the time you would spend staring at charts into technical learning instead — in the long run, that yields a higher ROI.

References

Note: This article is educational content, not investment advice. Investment decisions should be made based on your own judgment and responsibility.