수익률이 좋은 전략을 두 개 찾으면 절반씩 나눠 투자하고 싶어집니다. 전략이 두 개이니 위험도 자연스럽게 분산될 것처럼 보입니다.
하지만 두 전략이 같은 시장에서 같은 방향으로 움직인다면 이름만 다른 하나의 전략일 수 있습니다. EMA 20·100과 EMA 35·120은 진입 시점이 조금 달라도 상승 추세에서 함께 포지션을 보유하고 하락장에서 함께 손실을 낼 가능성이 높습니다.
이번에는 EMA, 채널 돌파, ROC, CCI 전략 여섯 개의 일간 수익률 상관계수를 계산했습니다. 2014~2021년 데이터에서 EMA 20·100과 가장 비슷한 전략, 가장 덜 비슷한 전략을 고른 뒤 2022~2025년 데이터에서 두 포트폴리오를 비교했습니다.
고상관 조합의 표본외 상관계수는 0.972, 저상관 조합은 0.338이었습니다. 상관관계를 낮추자 연 변동성은 12.03%에서 9.58%, MDD는 -15.02%에서 -13.08%로 줄었습니다.
다만 누적수익률도 +55.63%에서 +43.40%로 낮아졌습니다. 상관관계가 낮으면 위험 감소 효과가 커진다는 원리는 확인됐지만, 수익성이 약한 전략을 섞는 것까지 정당화하지는 못했습니다.
SYSTEM LAB 13 핵심 결과
- 국내 주식 30종목, 서로 다른 규칙의 시스템 6개 비교
- 선택 구간 고상관 파트너: EMA 35·120, 상관계수 0.962
- 선택 구간 저상관 파트너: CCI 역추세, 상관계수 0.525
- 표본외 연 변동성: 고상관 12.03% → 저상관 9.58%
- 표본외 MDD: 고상관 -15.02% → 저상관 -13.08%
- 표본외 누적수익률: 고상관 +55.63% → 저상관 +43.40%
1. 전략 수가 아니라 손익이 움직이는 방향을 본다
분산투자는 구성 요소의 개수를 늘리는 일이 아닙니다. 한 전략이 손실일 때 다른 전략이 같은 크기로 함께 손실을 내는지, 다른 경로를 만드는지를 확인하는 일입니다.
상관계수는 두 수익률이 같은 방향으로 움직이는 정도를 -1에서 +1 사이로 표시합니다.
| 상관계수 | 의미 | 분산 효과 |
|---|---|---|
| +1에 가까움 | 두 전략이 거의 같은 방향으로 움직임 | 위험 감소 효과가 작음 |
| 0에 가까움 | 두 전략의 움직임 관계가 약함 | 위험 감소 효과가 커짐 |
| -1에 가까움 | 두 전략이 반대 방향으로 움직임 | 이론상 가장 큰 위험 감소 |
포트폴리오 기대수익은 각 전략 수익률의 비중을 곱해 더할 수 있습니다. 위험은 그렇게 단순하게 더해지지 않습니다. 각 전략의 변동성과 두 전략 사이의 상관관계가 함께 결정합니다.
따라서 개별 전략의 누적수익률만 나열해서는 포트폴리오 위험을 알 수 없습니다. 같은 날짜의 일간 손익을 맞춰 놓고 상관계수와 합산 손익을 다시 계산해야 합니다.
2. 여섯 개 시스템을 후보로 둔다
후보는 앞선 LAB에서 검증한 규칙을 중심으로 구성했습니다.
| 시스템 | 핵심 규칙 | 전략 성격 |
|---|---|---|
| EMA 20·100 | 빠른 EMA와 느린 EMA 교차 | 중기 추세추종 |
| EMA 35·120 | 더 느린 EMA 교차 | 중장기 추세추종 |
| 돈치안 돌파 | 직전 20일 고가 돌파 | 가격 채널 추세추종 |
| ATR 돌파 | 20일 EMA + 2ATR 돌파 | 변동성 채널 추세추종 |
| ROC 추세 | 20일 ROC가 2%를 처음 돌파 | 모멘텀 추세추종 |
| CCI 역추세 | 추세·변동성 조건 안에서 CCI -100 탈출 | 단기 역추세 |
EMA 20·100을 기준 전략으로 고정했습니다. 선택 구간에서 이 전략과 상관계수가 가장 높은 후보를 고상관 파트너, 가장 낮은 후보를 저상관 파트너로 정합니다.
전략 이름만 보고 고상관과 저상관을 미리 지정하지 않았습니다. 2014~2021년 일간 수익률로 후보를 선택하고, 2022~2025년은 선택이 끝날 때까지 사용하지 않았습니다.
EMA 교차를 여러 종목과 구간에서 검증한 결과, 돈치안과 ATR 채널 돌파 비교, CCI 역추세에 추세·변동성 필터를 붙인 결과에서 각 규칙의 진입과 청산 구조를 먼저 확인할 수 있습니다.
3. 위험 크기를 맞춰야 상관관계 효과를 비교할 수 있다
CCI 역추세 전략은 시장에 머무는 시간이 짧고 원래 변동성이 낮습니다. 이를 변동성이 큰 EMA 전략과 원금 기준 50대 50으로 섞으면 상관관계와 무관하게 포트폴리오 변동성이 낮아질 수 있습니다.
그래서 선택 구간의 각 전략 연 변동성이 10%가 되도록 일간 수익률의 크기를 표준화했습니다. 그다음 EMA 20·100과 선택된 파트너를 각각 50%씩 배분했습니다.
| 비교 항목 | 고상관 조합 | 저상관 조합 |
|---|---|---|
| 기준 전략 | EMA 20·100, 50% | EMA 20·100, 50% |
| 파트너 | EMA 35·120, 50% | CCI 역추세, 50% |
| 구성 요소 기준 변동성 | 각각 연 10% | 각각 연 10% |
| 비중 결정 구간 | 2014~2021년 | 2014~2021년 |
| 최종 검증 | 2022~2025년 | 2022~2025년 |
이 표준화는 상관관계가 위험에 미치는 영향을 분리해 보기 위한 실험 장치입니다. CCI 전략은 선택 구간 변동성이 낮아 수익률을 약 3.62배 확대해야 10%에 도달했습니다. 실제 계좌에서 같은 배율의 레버리지를 사용하라는 뜻이 아니며, 차입 비용과 증거금 위험도 반영하지 않았습니다.
4. 실험 규칙
| 항목 | 이번 검증의 고정 규칙 |
|---|---|
| 입력 데이터 | yfinance 수정주가 일봉 시가·종가 |
| 종목 | 국내 대형·유동성 종목 30개 고정 목록 |
| 파트너 선택 | 2014~2021년 일간 수익률 상관계수 |
| 표본외 검증 | 2022~2025년 |
| 신호 확정 | 해당 거래일 종가 |
| 주문 체결 | 다음 거래일 시가 |
| 비용 | 매수·매도 각각 0.05% |
| 종목 포트폴리오 | 전략마다 30종목 초기 동일 비중 |
| 시스템 포트폴리오 | 변동성 표준화 후 두 전략 50대 50 |
| 검증 시작 | 현금 상태에서 새로운 진입 신호 대기 |
| 검증 종료 | 마지막 종가로 강제 청산 |
각 전략은 종목별로 독립적으로 매매합니다. 전략별 30종목 자산을 동일 비중으로 합친 뒤 일간 수익률을 계산하고, 마지막에 두 시스템 수익률을 합칩니다.
5. 전체 Python 코드
필요한 패키지는 터미널에서 먼저 설치합니다.
python -m pip install yfinance pandas numpy matplotlib pyarrow
아래 코드 하나가 가격 수집, 여섯 시스템의 신호와 체결, 상관계수 계산, 파트너 선택, 변동성 표준화, 표본외 포트폴리오와 차트 생성을 모두 수행합니다.
#!/usr/bin/env python3
"""고상관·저상관 시스템 조합의 표본외 변동성과 MDD를 비교한다."""
from __future__ import annotations
import time
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import yfinance as yf
from matplotlib import font_manager
TICKERS = ["005930.KS", "000660.KS", "005380.KS", "000270.KS", "035420.KS", "035720.KS", "068270.KS", "207940.KS", "105560.KS", "055550.KS", "066570.KS", "012330.KS", "028260.KS", "017670.KS", "009150.KS", "003670.KS", "042700.KS", "000810.KS", "196170.KQ", "247540.KQ", "086520.KQ", "058470.KQ", "035900.KQ", "041510.KQ", "067160.KQ", "263750.KQ", "293490.KQ", "112040.KQ", "036930.KQ", "240810.KQ"]
DOWNLOAD_START = "2012-01-01"
TRAIN_START = "2014-01-01"
TRAIN_END = "2022-01-01"
TEST_START = "2022-01-01"
TEST_END = "2026-01-01"
COST_RATE = 0.0005
TARGET_VOLATILITY = 0.10
MIN_ROWS = 252
PRICE_CACHE_FILE = Path("portfolio_correlation_prices.parquet")
CHART_FILE = Path("portfolio_correlation_chart.png")
RESULT_FILE = Path("portfolio_correlation_results.csv")
CORRELATION_FILE = Path("portfolio_correlation_matrix.csv")
STRATEGIES = {
"EMA 20·100": ("ema_20_100_entry", "ema_20_100_exit", None, None),
"EMA 35·120": ("ema_35_120_entry", "ema_35_120_exit", None, None),
"돈치안 돌파": ("donchian_entry", "channel_exit", 0.10, 120),
"ATR 돌파": ("atr_entry", "channel_exit", 0.10, 120),
"ROC 추세": ("roc_entry", "roc_exit", None, None),
"CCI 역추세": ("cci_entry", "cci_exit", 0.08, 20),
}
ANCHOR_STRATEGY = "EMA 20·100"
def read_price_cache(path: Path) -> dict[str, pd.DataFrame]:
cached = pd.read_parquet(path)
cached["date"] = pd.to_datetime(cached["date"])
return {ticker: group.set_index("date")[["open", "high", "low", "close", "volume"]].sort_index() for ticker, group in cached.groupby("ticker")}
def download_prices() -> dict[str, pd.DataFrame]:
if PRICE_CACHE_FILE.is_file():
return read_price_cache(PRICE_CACHE_FILE)
raw = yf.download(TICKERS, start=DOWNLOAD_START, end=TEST_END, auto_adjust=True, progress=False, group_by="ticker", threads=False)
if raw.empty:
raise RuntimeError("가격 데이터를 받지 못했습니다.")
prices = {}
for ticker in TICKERS:
frame = raw[ticker].rename(columns=str.lower).dropna(subset=["open", "high", "low", "close"]).copy()
if len(frame.loc[frame.index < TEST_END]) < MIN_ROWS:
for attempt in range(3):
retry = yf.download(ticker, start=DOWNLOAD_START, end=TEST_END, auto_adjust=True, progress=False, threads=False)
if isinstance(retry.columns, pd.MultiIndex):
retry.columns = retry.columns.get_level_values(0)
candidate = retry.rename(columns=str.lower).dropna(subset=["open", "high", "low", "close"]).copy()
if len(candidate) > len(frame):
frame = candidate
if len(frame.loc[frame.index < TEST_END]) >= MIN_ROWS:
break
time.sleep(1.0 + attempt)
if len(frame.loc[frame.index < TEST_END]) < MIN_ROWS:
raise RuntimeError(f"검증에 필요한 가격 데이터가 부족합니다: {ticker}")
prices[ticker] = frame
PRICE_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
cached = pd.concat([frame.reset_index(names="date").assign(ticker=ticker) for ticker, frame in prices.items()], ignore_index=True)
cached.to_parquet(PRICE_CACHE_FILE, index=False)
return prices
def mean_absolute_deviation(values: np.ndarray) -> float:
return float(np.mean(np.abs(values - values.mean())))
def prepare_price(raw: pd.DataFrame) -> pd.DataFrame:
data = raw.copy()
for fast_window, slow_window in ((20, 100), (35, 120)):
fast = data["close"].ewm(span=fast_window, adjust=False, min_periods=fast_window).mean()
slow = data["close"].ewm(span=slow_window, adjust=False, min_periods=slow_window).mean()
data[f"ema_{fast_window}_{slow_window}_entry"] = (fast > slow) & (fast.shift(1) <= slow.shift(1))
data[f"ema_{fast_window}_{slow_window}_exit"] = (fast < slow) & (fast.shift(1) >= slow.shift(1))
previous_close = data["close"].shift(1)
true_range = pd.concat([(data["high"] - data["low"]), (data["high"] - previous_close).abs(), (data["low"] - previous_close).abs()], axis=1).max(axis=1)
atr = true_range.ewm(alpha=1.0 / 14, adjust=False, min_periods=14).mean()
atr_center = data["close"].ewm(span=20, adjust=False, min_periods=20).mean()
atr_upper = atr_center + 2.0 * atr
donchian_upper = data["high"].shift(1).rolling(20, min_periods=20).max()
common_exit = data["low"].shift(1).rolling(10, min_periods=10).min()
data["donchian_entry"] = (data["close"] > donchian_upper) & (data["close"].shift(1) <= donchian_upper.shift(1))
data["atr_entry"] = (data["close"] > atr_upper) & (data["close"].shift(1) <= atr_upper.shift(1))
data["channel_exit"] = (data["close"] < common_exit) & (data["close"].shift(1) >= common_exit.shift(1))
roc = data["close"].pct_change(20) * 100.0
positive_episode = (roc <= 0.0).cumsum()
roc_candidate = roc > 2.0
previous_candidate = roc_candidate.groupby(positive_episode).cummax().groupby(positive_episode).shift(1, fill_value=False)
data["roc_entry"] = roc_candidate & ~previous_candidate
data["roc_exit"] = (roc < 0.0) & (roc.shift(1) >= 0.0)
typical_price = (data["high"] + data["low"] + data["close"]) / 3.0
typical_average = typical_price.rolling(20, min_periods=20).mean()
mean_deviation = typical_price.rolling(20, min_periods=20).apply(mean_absolute_deviation, raw=True)
cci = (typical_price - typical_average) / (0.015 * mean_deviation)
ema_long = data["close"].ewm(span=200, adjust=False, min_periods=200).mean()
trend_filter = (data["close"] > ema_long) & (ema_long > ema_long.shift(20))
volatility_filter = atr / data["close"] * 100.0 <= 5.0
data["cci_entry"] = (cci > -100.0) & (cci.shift(1) <= -100.0) & trend_filter & volatility_filter
data["cci_exit"] = (cci > 100.0) & (cci.shift(1) <= 100.0)
return data
def run_backtest(data: pd.DataFrame, entry_column: str, exit_column: str, stop_loss: float | None, maximum_holding_days: int | None) -> pd.DataFrame:
cash = 1.0
shares = 0.0
pending_order = None
entry_price = np.nan
entry_index = -1
daily_records = []
for index, (date, row) in enumerate(data.iterrows()):
if pending_order == "buy" and shares == 0.0:
entry_price = float(row["open"])
shares = cash * (1.0 - COST_RATE) / entry_price
cash = 0.0
entry_index = index
elif pending_order == "sell" and shares > 0.0:
cash = shares * float(row["open"]) * (1.0 - COST_RATE)
shares = 0.0
entry_price = np.nan
entry_index = -1
pending_order = None
if shares > 0.0:
holding_days = index - entry_index
close_return = float(row["close"]) / entry_price - 1.0
if stop_loss is not None and close_return <= -stop_loss:
pending_order = "sell"
elif bool(row[exit_column]):
pending_order = "sell"
elif maximum_holding_days is not None and holding_days >= maximum_holding_days:
pending_order = "sell"
elif bool(row[entry_column]):
pending_order = "buy"
daily_records.append({"date": date, "equity": cash + shares * float(row["close"])})
if shares > 0.0:
cash = shares * float(data.iloc[-1]["close"]) * (1.0 - COST_RATE)
daily_records[-1]["equity"] = cash
return pd.DataFrame(daily_records).set_index("date")
def build_strategy_returns(prepared: dict[str, pd.DataFrame], start: str, end: str) -> pd.DataFrame:
calendar = pd.DatetimeIndex(sorted(set().union(*(data.loc[(data.index >= start) & (data.index < end)].index for data in prepared.values()))))
strategy_returns = {}
for label, (entry_column, exit_column, stop_loss, maximum_holding_days) in STRATEGIES.items():
equity_columns = []
for ticker, full_data in prepared.items():
data = full_data.loc[(full_data.index >= start) & (full_data.index < end)].copy()
daily = run_backtest(data, entry_column, exit_column, stop_loss, maximum_holding_days)
equity_columns.append(daily["equity"].reindex(calendar).ffill().fillna(1.0).rename(ticker))
portfolio_equity = pd.concat(equity_columns, axis=1).mean(axis=1)
strategy_returns[label] = portfolio_equity.pct_change().fillna(0.0)
return pd.DataFrame(strategy_returns)
def summarize_returns(returns: pd.Series) -> dict[str, float]:
equity = (1.0 + returns).cumprod()
drawdown = equity / equity.cummax() - 1.0
elapsed_years = max((equity.index[-1] - equity.index[0]).days / 365.25, 1.0 / 365.25)
annual_volatility = float(returns.std(ddof=0) * np.sqrt(252.0))
cagr = float(equity.iloc[-1] ** (1.0 / elapsed_years) - 1.0)
return {"return": float(equity.iloc[-1] - 1.0), "cagr": cagr, "annual_volatility": annual_volatility, "mdd": float(drawdown.min()), "calmar": cagr / abs(float(drawdown.min())) if drawdown.min() < 0.0 else np.nan}
def configure_font() -> None:
available_fonts = {item.name for item in font_manager.fontManager.ttflist}
for font_name in ("Malgun Gothic", "AppleGothic", "UnDotum", "DejaVu Sans"):
if font_name in available_fonts:
plt.rcParams["font.family"] = font_name
break
plt.rcParams["axes.unicode_minus"] = False
def draw_chart(train_correlation: pd.DataFrame, test_returns: pd.DataFrame, pair_returns: pd.DataFrame, results: pd.DataFrame, high_partner: str, low_partner: str) -> None:
colors = {"고상관 조합": "#F59E0B", "저상관 조합": "#0F766E", "EMA 20·100 단독": "#64748B"}
fig = plt.figure(figsize=(14, 10), facecolor="white")
grid = fig.add_gridspec(2, 2, height_ratios=[1.35, 1.0], hspace=0.34, wspace=0.28)
equity_axis = fig.add_subplot(grid[0, :])
matrix_axis = fig.add_subplot(grid[1, 0])
risk_axis = fig.add_subplot(grid[1, 1])
for label in pair_returns:
equity = (1.0 + pair_returns[label]).cumprod()
row = results.loc[results["portfolio"] == label].iloc[0]
equity_axis.plot(equity.index, equity, color=colors[label], linewidth=2.5 if "조합" in label else 1.8, label=f"{label} 수익률 {row['return']:+.1%} · MDD {row['mdd']:.1%}")
equity_axis.axhline(1.0, color="#9CA3AF", linewidth=1.0)
equity_axis.set_title("같은 기준 변동성으로 맞춘 뒤 남겨둔 구간의 성과만 비교했다", fontsize=18, fontweight="bold", pad=16)
equity_axis.set_ylabel("표본외 누적 자산")
equity_axis.legend(loc="upper left", frameon=False, ncol=3)
matrix_values = train_correlation.to_numpy()
image = matrix_axis.imshow(matrix_values, cmap="RdYlGn_r", vmin=0.0, vmax=1.0, aspect="auto")
matrix_axis.set_xticks(np.arange(len(train_correlation.columns)), train_correlation.columns, rotation=40, ha="right", fontsize=8)
matrix_axis.set_yticks(np.arange(len(train_correlation.index)), train_correlation.index, fontsize=8)
matrix_axis.set_title("변수 선택 구간 일간 수익률 상관계수", fontsize=14, fontweight="bold")
for row in range(len(train_correlation.index)):
for column in range(len(train_correlation.columns)):
matrix_axis.text(column, row, f"{matrix_values[row, column]:.2f}", ha="center", va="center", fontsize=7, color="#14213D")
plt.colorbar(image, ax=matrix_axis, fraction=0.046, pad=0.04)
labels = ["고상관 조합", "저상관 조합"]
x = np.arange(len(labels))
width = 0.34
pair_rows = results.set_index("portfolio").loc[labels]
risk_axis.bar(x - width / 2, pair_rows["annual_volatility"] * 100.0, width, color="#94A3B8", label="연 변동성")
risk_axis.bar(x + width / 2, pair_rows["mdd"].abs() * 100.0, width, color=[colors[label] for label in labels], label="MDD 절댓값")
risk_axis.set_xticks(x, [f"고상관\nEMA+{high_partner}", f"저상관\nEMA+{low_partner}"])
risk_axis.set_ylabel("위험(%)")
risk_axis.set_title("표본외 위험 비교", fontsize=14, fontweight="bold")
risk_axis.legend(frameon=False)
for container in risk_axis.containers:
risk_axis.bar_label(container, labels=[f"{bar.get_height():.1f}%" for bar in container], padding=3, fontsize=9)
for axis in (equity_axis, risk_axis):
axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
axis.spines["top"].set_visible(False)
axis.spines["right"].set_visible(False)
fig.suptitle("수익률이 함께 움직이지 않는 시스템을 섞을수록 포트폴리오 위험이 낮아졌다", fontsize=21, fontweight="bold", y=0.99)
CHART_FILE.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(CHART_FILE, dpi=160, bbox_inches="tight", facecolor="white")
plt.close(fig)
configure_font()
prices = download_prices()
prepared = {ticker: prepare_price(frame) for ticker, frame in prices.items()}
train_returns = build_strategy_returns(prepared, TRAIN_START, TRAIN_END)
test_returns = build_strategy_returns(prepared, TEST_START, TEST_END)
train_correlation = train_returns.corr()
eligible = [strategy for strategy in STRATEGIES if strategy != ANCHOR_STRATEGY]
high_partner = train_correlation.loc[ANCHOR_STRATEGY, eligible].idxmax()
low_partner = train_correlation.loc[ANCHOR_STRATEGY, eligible].idxmin()
train_volatility = train_returns.std(ddof=0) * np.sqrt(252.0)
volatility_scale = TARGET_VOLATILITY / train_volatility
scaled_test_returns = test_returns.mul(volatility_scale, axis=1)
pair_returns = pd.DataFrame({
"EMA 20·100 단독": scaled_test_returns[ANCHOR_STRATEGY],
"고상관 조합": 0.5 * scaled_test_returns[ANCHOR_STRATEGY] + 0.5 * scaled_test_returns[high_partner],
"저상관 조합": 0.5 * scaled_test_returns[ANCHOR_STRATEGY] + 0.5 * scaled_test_returns[low_partner],
})
result_rows = []
for label, returns in pair_returns.items():
metrics = summarize_returns(returns)
if label == "고상관 조합":
partner = high_partner
elif label == "저상관 조합":
partner = low_partner
else:
partner = "-"
result_rows.append({"portfolio": label, "partner": partner, "train_correlation": 1.0 if partner == "-" else float(train_correlation.loc[ANCHOR_STRATEGY, partner]), "test_correlation": 1.0 if partner == "-" else float(test_returns[[ANCHOR_STRATEGY, partner]].corr().iloc[0, 1]), **metrics})
results = pd.DataFrame(result_rows)
train_correlation.to_csv(CORRELATION_FILE, encoding="utf-8-sig")
results.to_csv(RESULT_FILE, index=False, encoding="utf-8-sig")
draw_chart(train_correlation, test_returns, pair_returns, results, high_partner, low_partner)
print(f"고상관 파트너: {high_partner} · 선택 상관 {train_correlation.loc[ANCHOR_STRATEGY, high_partner]:.3f}")
print(f"저상관 파트너: {low_partner} · 선택 상관 {train_correlation.loc[ANCHOR_STRATEGY, low_partner]:.3f}")
print(results.to_string(index=False, formatters={"train_correlation": "{:.3f}".format, "test_correlation": "{:.3f}".format, "return": "{:+.2%}".format, "cagr": "{:+.2%}".format, "annual_volatility": "{:.2%}".format, "mdd": "{:.2%}".format, "calmar": "{:.2f}".format}))
print(f"결과 저장: {RESULT_FILE}")
print(f"상관행렬 저장: {CORRELATION_FILE}")
print(f"차트 저장: {CHART_FILE}")
train_correlation에서 파트너를 고른 뒤 test_returns로 표본외 성과를 계산합니다. 검증 구간 상관계수나 MDD를 보고 파트너를 다시 고르면 결과를 미리 본 선택이 되므로 코드에서도 두 단계를 분리했습니다.
6. 추세 전략끼리는 생각보다 비슷하게 움직였다
선택 구간의 일간 수익률 상관계수입니다.
| 기준: EMA 20·100 | 상관계수 |
|---|---|
| EMA 35·120 | 0.962 |
| ROC 추세 | 0.829 |
| ATR 돌파 | 0.786 |
| 돈치안 돌파 | 0.784 |
| CCI 역추세 | 0.525 |
EMA 기간만 바꾼 EMA 35·120이 가장 높은 0.962였습니다. 돈치안, ATR, ROC는 계산 지표와 진입 조건이 다르지만 모두 상승 추세를 따라가려는 구조여서 0.78~0.83의 양의 상관관계를 보였습니다.
CCI 역추세는 0.525로 가장 낮았습니다. 장기 추세를 오래 보유하는 전략과 달리 과매도 탈출 뒤 짧은 반등을 노리고 최대 20거래일 안에 청산하기 때문입니다.
전략 이름과 지표가 다르다고 분산되는 것은 아닙니다. 결국 어떤 시장 상황에서 포지션을 보유하고 손익이 발생하는지를 확인해야 합니다.
7. 남겨둔 구간에서도 상관관계 순서가 유지됐다
작성 환경에서 실행한 표본외 결과입니다.
| 포트폴리오 | 선택 상관 | 표본외 상관 | 누적수익률 | 연 변동성 | MDD | Calmar |
|---|---|---|---|---|---|---|
| EMA 20·100 단독 | 1.000 | 1.000 | +59.59% | 12.78% | -16.32% | 0.76 |
| 고상관: EMA 20·100 + EMA 35·120 | 0.962 | 0.972 | +55.63% | 12.03% | -15.02% | 0.78 |
| 저상관: EMA 20·100 + CCI 역추세 | 0.525 | 0.338 | +43.40% | 9.58% | -13.08% | 0.72 |

고상관 파트너는 표본외에서도 EMA 20·100과 0.972로 거의 같은 방향으로 움직였습니다. 두 EMA를 섞어도 연 변동성은 단독 12.78%에서 12.03%로 조금 줄었고 MDD도 -16.32%에서 -15.02%로 1.30%p 완화되는 데 그쳤습니다.
저상관 파트너의 표본외 상관계수는 0.338로 더 낮아졌습니다. 연 변동성은 고상관 조합보다 2.45%p, MDD는 1.94%p 낮았습니다. 수익률이 함께 움직이지 않는 시스템을 섞을수록 포트폴리오 위험이 더 낮아진다는 결과입니다.
8. 위험이 줄었지만 더 좋은 포트폴리오는 아니었다
저상관 조합의 누적수익률은 +43.40%로 고상관 조합의 +55.63%보다 12.23%p 낮았습니다. MDD는 개선됐지만 CAGR을 MDD로 나눈 Calmar는 0.78에서 0.72로 오히려 낮아졌습니다.
원인은 CCI 역추세 전략의 낮은 기대수익입니다. 이 전략의 선택 구간 CAGR은 +0.19%, 표본외 CAGR은 +1.76%였습니다. EMA와 다른 손익 경로를 만들었지만 포트폴리오 수익을 충분히 보완하지 못했습니다.
따라서 상관계수가 가장 낮은 전략을 무조건 추가한다는 선택도 잘못입니다. 먼저 개별 전략이 충분한 거래 수와 독립적인 수익 근거를 갖췄는지 확인하고, 그다음 상관관계를 이용해 조합해야 합니다.
상관계수는 위험을 줄이는 방향을 알려주지만 낮은 기대수익을 높은 기대수익으로 바꾸지는 않습니다. 수익률만 보면 놓치는 MDD와 Calmar 해석이 시스템 포트폴리오에서도 필요한 이유입니다.
9. 상관계수 0.5도 완전히 낮은 값은 아니다
선택 구간의 최저 상관계수는 0.525였습니다. 음의 상관은커녕 0보다도 상당히 높습니다. 여섯 전략이 모두 같은 국내 주식 30종목을 매수 방향으로만 거래하기 때문입니다.
지표와 보유 기간을 바꿔도 시장 전체가 급락하면 함께 손실을 낼 수 있습니다. 실제로 표본외 자산곡선에서도 두 포트폴리오는 비슷한 시기에 낙폭을 경험했습니다.
더 큰 분산 효과가 필요하다면 전략만 바꾸는 데서 멈추지 않고 시장과 자산군을 나눠야 합니다. 주식 추세추종, 채권, 원자재, 환율처럼 수익 원천이 다른 시장을 함께 검토해야 하지만, 이를 위해서는 각 시장의 데이터와 실제 거래 조건이 별도로 준비돼야 합니다.
10. 실전 시스템 포트폴리오를 고르는 순서
첫째, 개별 시스템부터 통과시킨다
거래 수, 표본외 성과, MDD, 비용 민감도와 매매 논리를 확인합니다. 단독으로 사용할 근거가 부족한 시스템은 낮은 상관계수만으로 포함하지 않습니다.
둘째, 같은 날짜의 일간 손익을 맞춘다
누적수익률끼리 상관계수를 구하면 안 됩니다. 전략별 일간 또는 월간 수익률을 같은 날짜에 맞춰 계산합니다.
셋째, 높은 상관관계의 중복 전략을 줄인다
EMA 기간만 조금 다른 여러 전략은 거래 수만 늘리고 분산 효과는 제한적일 수 있습니다. 같은 계열에서 대표 전략을 하나 남길지 검토합니다.
넷째, 비중을 포함한 포트폴리오를 다시 백테스트한다
개별 MDD를 평균한 값이 포트폴리오 MDD가 아닙니다. 비중을 적용한 일간 손익을 합쳐 새로운 자산곡선과 MDD를 계산해야 합니다.
다섯째, 상관관계 변화도 감시한다
과거에 낮았던 상관관계가 급락장에서 높아질 수 있습니다. 전체 기간 평균뿐 아니라 이동 상관계수와 스트레스 구간을 함께 확인해야 합니다.
11. 이번 실험의 한계
첫째, 현재 확인 가능한 30종목을 사용해 상장폐지 종목이 빠진 생존 편향이 남아 있습니다.
둘째, yfinance 수정주가 일봉과 단순화한 비용을 사용했습니다. 세금, 호가 스프레드, 시장 충격과 주문 실패를 완전히 반영하지 않았습니다.
셋째, 각 전략의 선택 구간 변동성을 10%로 맞췄습니다. 특히 CCI 전략에는 약 3.62배의 수익률 배율이 적용됐으며 실제 레버리지 비용과 증거금 위험은 포함하지 않았습니다.
넷째, 시스템 비중을 50대 50으로 고정했습니다. 최소분산, 위험균형이나 최대 샤프 비중을 사용하면 결과가 달라질 수 있습니다.
다섯째, 같은 국내 주식 종목군과 매수 전략만 사용했습니다. 다른 자산군을 섞었을 때의 분산 효과를 검증한 결과가 아닙니다.
12. 정리
이번 시스템 포트폴리오 실험에서 확인한 사실은 세 가지입니다.
- EMA 20·100과 EMA 35·120의 표본외 상관계수는 0.972로, 기간만 다른 추세 전략을 섞어도 분산 효과는 작았습니다.
- CCI 역추세를 섞은 저상관 조합은 연 변동성을 12.03%에서 9.58%, MDD를 -15.02%에서 -13.08%로 낮췄습니다.
- 저상관 조합은 누적수익률과 Calmar도 낮아져, 상관관계만으로 포트폴리오를 선택할 수는 없었습니다.
상관관계가 낮을수록 포트폴리오 위험 감소 효과는 커집니다. 다만 그 효과를 실전 성과로 연결하려면 각 구성 전략이 먼저 독립적인 수익성과 충분한 거래 근거를 갖춰야 합니다.
다음 글에서는 백테스트를 통과한 시스템을 모의매매로 옮길 때 필요한 신호, 주문, 체결, 잔고 로그를 만듭니다. 백테스트와 실시간 결과가 달라졌을 때 어느 단계에서 문제가 생겼는지 추적하고 시스템을 중지·재개하는 기준까지 정리합니다.
이 글은 과거 데이터를 이용한 백테스트 예제이며 특정 종목이나 전략의 미래 수익을 보장하지 않습니다. 실제 투자에서는 세금, 수수료, 슬리피지, 체결 가능성과 계좌 위험을 별도로 검토해야 합니다.