백테스트 프로그램에 빠른 이동평균과 느린 이동평균의 범위를 넣으면 수십 개 결과가 한꺼번에 나옵니다. 그중 누적수익률 1위만 골라 실전에 적용하면 될 것처럼 보입니다.
하지만 그 숫자는 전략의 실력일 수도 있고, 특정 기간에 우연히 잘 맞은 값일 수도 있습니다. 반대로 주변 변수의 성과가 고른 구간을 선택한다고 과최적화 문제가 자동으로 사라지는 것도 아닙니다.
이번에는 EMA 교차 전략의 기간을 49개 조합으로 바꿨습니다. 2014~2021년은 변수를 고르는 데만 사용하고, 2022~2025년은 선택이 끝날 때까지 보지 않은 검증 구간으로 남겼습니다.
선택 구간 수익률 1위는 EMA 15일·60일 조합의 +248.90%였습니다. 이 조합은 검증 구간에서도 +89.85%로 무너지지 않았지만 순위는 1위에서 13위로 내려갔습니다. 주변 성과가 가장 고른 EMA 35일·120일 조합은 +224.11%에서 +79.97%로 이동했고 검증 순위는 41위였습니다.
결과는 의외로 단순합니다. 최고점을 피하는 것만으로 좋은 변수를 고를 수 없고, 평탄한 구간만 찾는 것만으로도 부족했습니다. 선택 구간의 높이, 주변의 모양, 남겨둔 데이터의 성과를 순서대로 확인해야 합니다.
SYSTEM LAB 11 핵심 결과
- 빠른 EMA 7개 × 느린 EMA 7개, 총 49개 조합 검증
- 선택 구간 최고점: EMA 15·60, +248.90%, MDD -17.02%
- 최고점의 검증 성과: +89.85%, MDD -16.87%, 전체 13위
- 평탄 구간 후보: EMA 35·120, 주변 9개 수익률 중앙값 +204.36%
- 평탄 후보의 검증 성과: +79.97%, MDD -22.57%, 전체 41위
- 선택 구간과 검증 구간의 조합별 순위 상관: -0.10
1. 과최적화는 수익률이 높다는 뜻이 아니다
과최적화는 과거 데이터의 우연한 특징까지 규칙으로 학습한 상태입니다. 백테스트 수익률이 높다는 사실만으로 과최적화라고 단정할 수는 없습니다. 낮은 수익률의 전략도 불필요한 조건이 많으면 과최적화될 수 있습니다.
문제는 선택 과정입니다.
- 같은 데이터에서 수십 개 변수를 시험합니다.
- 가장 높은 결과만 남깁니다.
- 그 결과를 다시 같은 데이터로 평가합니다.
- 선택에 사용한 성과를 미래 기대수익처럼 받아들입니다.
49개 조합에 실력이 전혀 없더라도 우연히 1등은 생깁니다. 따라서 얼마나 높았는가와 함께 주변 값에서도 유지되는가, 보지 않은 구간에서도 유지되는가를 물어야 합니다.
EMA 교차를 세 구간과 30종목에 적용한 실험에서는 하나의 고정 변수가 시장과 종목에 따라 달라지는 모습을 확인했습니다. 이번에는 종목군을 고정하고 변수 선택 자체가 결과에 미치는 영향을 봅니다.
2. 최고점과 평탄 구간을 따로 정의한다
최고점 후보는 선택 구간 누적수익률이 가장 높은 조합입니다. 별도의 판단이 필요하지 않습니다.
평탄 구간 후보는 계산 절차를 하나 더 둡니다. 각 조합을 중심으로 빠른 EMA 앞뒤 한 칸, 느린 EMA 앞뒤 한 칸을 묶어 3×3 이웃을 만듭니다. 이웃 9개의 수익률 중앙값에서 표준편차를 뺀 값을 평탄 점수로 사용했습니다.
평탄 점수 = 주변 9개 누적수익률 중앙값 - 주변 9개 누적수익률 표준편차
주변 수익률이 높고 편차가 작을수록 점수가 높아집니다. 가장자리 조합은 완전한 3×3 이웃을 만들 수 없으므로 평탄 후보 선정에서 제외했습니다. 이 점수는 정답 공식이 아니라 높으면서 덜 뾰족한 구간을 찾기 위한 단순한 비교 기준입니다.
중요한 원칙은 검증 구간을 후보 선택에 쓰지 않는 것입니다. 최고점과 평탄 후보를 2014~2021년 데이터로 확정한 다음에만 2022~2025년 결과를 열었습니다.
3. 실험 규칙을 고정한다
변수 외의 조건은 모두 같게 두었습니다.
| 항목 | 이번 검증의 고정 규칙 |
|---|---|
| 입력 데이터 | yfinance 수정주가 일봉 시가·종가 |
| 종목 | 국내 대형·유동성 종목 30개 고정 목록 |
| 선택 구간 | 2014~2021년 |
| 남겨둔 검증 구간 | 2022~2025년 |
| 빠른 EMA | 10, 15, 20, 25, 30, 35, 40일 |
| 느린 EMA | 60, 80, 100, 120, 140, 160, 180일 |
| 진입 | 빠른 EMA가 느린 EMA를 상향 돌파 |
| 청산 | 빠른 EMA가 느린 EMA를 하향 돌파 |
| 체결 | 종가 신호 확정 후 다음 거래일 시가 |
| 방향 | 현물 매수만 허용 |
| 비용 | 매수·매도 각각 0.05% |
| 포트폴리오 | 종목별 초기 자산을 동일 비중으로 평균 |
| 구간 종료 | 마지막 종가로 강제 청산 |
30개 종목은 현재 확인 가능한 대형주와 유동성 종목으로 고정했습니다. 매 조합마다 유리한 종목을 다시 고르지 않았습니다. 신호가 발생한 종가로 체결하지 않고 다음 시가로 주문하는 구조는 종가 신호를 바로 샀더니 백테스트가 틀어진 이유와 같습니다.
4. 전체 Python 코드
필요한 패키지는 터미널에서 먼저 설치합니다.
python -m pip install yfinance pandas numpy matplotlib pyarrow
아래 코드 하나가 가격 데이터를 내려받고, 49개 EMA 조합의 선택·검증 성과를 계산하고, 최고점과 평탄 후보를 고른 뒤 히트맵과 CSV를 저장합니다.
#!/usr/bin/env python3
"""EMA 변수 격자에서 최고점과 주변 성과가 평탄한 조합을 표본외 구간으로 비교한다."""
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"]
FAST_WINDOWS = [10, 15, 20, 25, 30, 35, 40]
SLOW_WINDOWS = [60, 80, 100, 120, 140, 160, 180]
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
MIN_ROWS = 252
PRICE_CACHE_FILE = Path("parameter_stability_prices.parquet")
CHART_FILE = Path("parameter_stability_chart.png")
RESULT_FILE = Path("parameter_stability_results.csv")
TRADE_FILE = Path("parameter_stability_trades.csv")
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 prepare_price(raw: pd.DataFrame) -> pd.DataFrame:
data = raw.copy()
for window in sorted(set(FAST_WINDOWS + SLOW_WINDOWS)):
data[f"ema_{window}"] = data["close"].ewm(span=window, adjust=False, min_periods=window).mean()
return data
def run_strategy(data: pd.DataFrame, fast_window: int, slow_window: int, start: str, end: str) -> tuple[pd.DataFrame, pd.DataFrame]:
fast = data[f"ema_{fast_window}"]
slow = data[f"ema_{slow_window}"]
regime = fast > slow
entry_signal = regime & ~regime.shift(1, fill_value=False)
sample = data.loc[(data.index >= start) & (data.index < end)].copy()
if len(sample) < MIN_ROWS:
return pd.DataFrame(), pd.DataFrame()
sample_regime = regime.reindex(sample.index).fillna(False)
sample_entry = entry_signal.reindex(sample.index).fillna(False)
entry_dates = sample.index[sample_entry]
position_close = pd.Series(False, index=sample.index)
if len(entry_dates):
position_close.loc[entry_dates[0]:] = sample_regime.loc[entry_dates[0]:]
position_open = position_close.shift(1, fill_value=False)
previous_position = position_open.shift(1, fill_value=False)
entry_day = position_open & ~previous_position
holding_day = position_open & previous_position
exit_day = ~position_open & previous_position
previous_close = sample["close"].shift(1)
factor = pd.Series(1.0, index=sample.index)
factor.loc[entry_day] = sample.loc[entry_day, "close"] / sample.loc[entry_day, "open"] * (1.0 - COST_RATE)
factor.loc[holding_day] = sample.loc[holding_day, "close"] / previous_close.loc[holding_day]
factor.loc[exit_day] = sample.loc[exit_day, "open"] / previous_close.loc[exit_day] * (1.0 - COST_RATE)
if bool(position_open.iloc[-1]):
factor.iloc[-1] *= 1.0 - COST_RATE
daily = pd.DataFrame({"equity": factor.cumprod(), "exposure": position_open.astype(float)})
entries = list(sample.index[entry_day])
exits = list(sample.index[exit_day])
trades = []
for entry_date in entries:
later_exits = [exit_date for exit_date in exits if exit_date > entry_date]
if later_exits:
exit_date = later_exits[0]
exit_price = float(sample.loc[exit_date, "open"])
forced_exit = False
else:
exit_date = sample.index[-1]
exit_price = float(sample.loc[exit_date, "close"])
forced_exit = True
entry_price = float(sample.loc[entry_date, "open"])
holding_days = int(sample.index.get_loc(exit_date) - sample.index.get_loc(entry_date))
trades.append({"entry_date": entry_date, "exit_date": exit_date, "entry_price": entry_price, "exit_price": exit_price, "trade_return": exit_price * (1.0 - COST_RATE) / (entry_price / (1.0 - COST_RATE)) - 1.0, "holding_days": holding_days, "forced_exit": forced_exit})
return pd.DataFrame(trades), daily
def evaluate_combo(prepared: dict[str, pd.DataFrame], fast_window: int, slow_window: int, start: str, end: str) -> tuple[dict[str, float], pd.DataFrame, pd.DataFrame]:
calendar = pd.DatetimeIndex(sorted(set().union(*(data.loc[(data.index >= start) & (data.index < end)].index for data in prepared.values()))))
equity_series = []
exposure_series = []
trade_frames = []
for ticker, data in prepared.items():
trades, daily = run_strategy(data, fast_window, slow_window, start, end)
if daily.empty:
continue
trades.insert(0, "ticker", ticker)
trade_frames.append(trades)
equity_series.append(daily["equity"].reindex(calendar).ffill().fillna(1.0).rename(ticker))
exposure_series.append(daily["exposure"].reindex(calendar).ffill().fillna(0.0).rename(ticker))
portfolio = pd.DataFrame({"equity": pd.concat(equity_series, axis=1).mean(axis=1), "exposure": pd.concat(exposure_series, axis=1).mean(axis=1)})
trades = pd.concat(trade_frames, ignore_index=True)
drawdown = portfolio["equity"] / portfolio["equity"].cummax() - 1.0
winners = trades.loc[trades["trade_return"] > 0.0, "trade_return"]
losers = trades.loc[trades["trade_return"] < 0.0, "trade_return"]
summary = {"return": float(portfolio["equity"].iloc[-1] - 1.0), "mdd": float(drawdown.min()), "profit_factor": float(winners.sum() / losers.abs().sum()), "win_rate": float((trades["trade_return"] > 0.0).mean()), "trade_count": len(trades), "exposure": float(portfolio["exposure"].mean())}
return summary, trades, portfolio
def select_candidates(results: pd.DataFrame) -> tuple[tuple[int, int], tuple[int, int], pd.DataFrame]:
train_matrix = results.pivot(index="fast_window", columns="slow_window", values="train_return").reindex(index=FAST_WINDOWS, columns=SLOW_WINDOWS)
peak_row = results.loc[results["train_return"].idxmax()]
peak = (int(peak_row["fast_window"]), int(peak_row["slow_window"]))
plateau_rows = []
for fast_index in range(1, len(FAST_WINDOWS) - 1):
for slow_index in range(1, len(SLOW_WINDOWS) - 1):
neighborhood = train_matrix.iloc[fast_index - 1:fast_index + 2, slow_index - 1:slow_index + 2].to_numpy().ravel()
median_return = float(np.nanmedian(neighborhood))
standard_deviation = float(np.nanstd(neighborhood))
plateau_rows.append({"fast_window": FAST_WINDOWS[fast_index], "slow_window": SLOW_WINDOWS[slow_index], "neighbor_median": median_return, "neighbor_std": standard_deviation, "neighbor_min": float(np.nanmin(neighborhood)), "plateau_score": median_return - standard_deviation})
plateau_table = pd.DataFrame(plateau_rows)
stable_row = plateau_table.loc[plateau_table["plateau_score"].idxmax()]
stable = (int(stable_row["fast_window"]), int(stable_row["slow_window"]))
return peak, stable, plateau_table
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_heatmap(axis: plt.Axes, matrix: pd.DataFrame, title: str, peak: tuple[int, int], stable: tuple[int, int]) -> None:
values = matrix.to_numpy() * 100.0
image = axis.imshow(values, cmap="YlGn", aspect="auto", origin="lower")
axis.set_xticks(np.arange(len(SLOW_WINDOWS)), SLOW_WINDOWS)
axis.set_yticks(np.arange(len(FAST_WINDOWS)), FAST_WINDOWS)
axis.set_xlabel("느린 EMA 기간")
axis.set_ylabel("빠른 EMA 기간")
axis.set_title(title, fontsize=15, fontweight="bold")
for row in range(len(FAST_WINDOWS)):
for column in range(len(SLOW_WINDOWS)):
axis.text(column, row, f"{values[row, column]:.0f}", ha="center", va="center", fontsize=8, color="#14213D")
peak_position = (SLOW_WINDOWS.index(peak[1]), FAST_WINDOWS.index(peak[0]))
stable_position = (SLOW_WINDOWS.index(stable[1]), FAST_WINDOWS.index(stable[0]))
axis.add_patch(plt.Rectangle((peak_position[0] - 0.46, peak_position[1] - 0.46), 0.92, 0.92, fill=False, edgecolor="#DC2626", linewidth=3.0))
axis.add_patch(plt.Rectangle((stable_position[0] - 0.40, stable_position[1] - 0.40), 0.80, 0.80, fill=False, edgecolor="#2563EB", linewidth=3.0))
plt.colorbar(image, ax=axis, fraction=0.046, pad=0.04, label="누적수익률(%)")
def draw_chart(results: pd.DataFrame, peak: tuple[int, int], stable: tuple[int, int]) -> None:
train_matrix = results.pivot(index="fast_window", columns="slow_window", values="train_return").reindex(index=FAST_WINDOWS, columns=SLOW_WINDOWS)
test_matrix = results.pivot(index="fast_window", columns="slow_window", values="test_return").reindex(index=FAST_WINDOWS, columns=SLOW_WINDOWS)
peak_row = results.loc[(results["fast_window"] == peak[0]) & (results["slow_window"] == peak[1])].iloc[0]
stable_row = results.loc[(results["fast_window"] == stable[0]) & (results["slow_window"] == stable[1])].iloc[0]
fig = plt.figure(figsize=(14, 10), facecolor="white")
grid = fig.add_gridspec(2, 2, height_ratios=[1.55, 0.85], hspace=0.35, wspace=0.24)
train_axis = fig.add_subplot(grid[0, 0])
test_axis = fig.add_subplot(grid[0, 1])
comparison_axis = fig.add_subplot(grid[1, :])
draw_heatmap(train_axis, train_matrix, "변수 선택 구간", peak, stable)
draw_heatmap(test_axis, test_matrix, "남겨둔 검증 구간", peak, stable)
labels = [f"최고점\nEMA {peak[0]}/{peak[1]}", f"평탄 구간\nEMA {stable[0]}/{stable[1]}"]
x = np.arange(2)
width = 0.32
train_values = np.array([peak_row["train_return"], stable_row["train_return"]]) * 100.0
test_values = np.array([peak_row["test_return"], stable_row["test_return"]]) * 100.0
comparison_axis.bar(x - width / 2, train_values, width, color="#0F766E", label="변수 선택 구간")
comparison_axis.bar(x + width / 2, test_values, width, color="#F59E0B", label="남겨둔 검증 구간")
comparison_axis.set_xticks(x, labels)
comparison_axis.set_ylabel("누적수익률(%)")
comparison_axis.set_title("최고점과 평탄 구간 후보의 성과 이동", fontsize=15, fontweight="bold")
comparison_axis.legend(frameon=False, ncol=2)
for container in comparison_axis.containers:
comparison_axis.bar_label(container, labels=[f"{bar.get_height():.1f}%" for bar in container], padding=4, fontsize=10)
comparison_axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
comparison_axis.spines["top"].set_visible(False)
comparison_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()}
result_rows = []
for fast_window in FAST_WINDOWS:
for slow_window in SLOW_WINDOWS:
train_summary, _, _ = evaluate_combo(prepared, fast_window, slow_window, TRAIN_START, TRAIN_END)
test_summary, _, _ = evaluate_combo(prepared, fast_window, slow_window, TEST_START, TEST_END)
result_rows.append({"fast_window": fast_window, "slow_window": slow_window, **{f"train_{key}": value for key, value in train_summary.items()}, **{f"test_{key}": value for key, value in test_summary.items()}})
results = pd.DataFrame(result_rows)
peak, stable, plateau_table = select_candidates(results)
results = results.merge(plateau_table, on=["fast_window", "slow_window"], how="left")
results["candidate"] = ""
results.loc[(results["fast_window"] == peak[0]) & (results["slow_window"] == peak[1]), "candidate"] = "최고점"
results.loc[(results["fast_window"] == stable[0]) & (results["slow_window"] == stable[1]), "candidate"] = "평탄 구간"
candidate_trades = []
for label, (fast_window, slow_window) in {"최고점": peak, "평탄 구간": stable}.items():
for sample, start, end in (("변수 선택", TRAIN_START, TRAIN_END), ("검증", TEST_START, TEST_END)):
_, trades, _ = evaluate_combo(prepared, fast_window, slow_window, start, end)
candidate_trades.append(trades.assign(candidate=label, sample=sample, fast_window=fast_window, slow_window=slow_window))
tagged_trades = pd.concat(candidate_trades, ignore_index=True)
results.to_csv(RESULT_FILE, index=False, encoding="utf-8-sig")
tagged_trades.to_csv(TRADE_FILE, index=False, encoding="utf-8-sig")
draw_chart(results, peak, stable)
candidate_rows = results.loc[results["candidate"] != "", ["candidate", "fast_window", "slow_window", "train_return", "train_mdd", "test_return", "test_mdd", "neighbor_median", "neighbor_std", "neighbor_min"]]
print(candidate_rows.to_string(index=False, formatters={"train_return": "{:+.2%}".format, "train_mdd": "{:.2%}".format, "test_return": "{:+.2%}".format, "test_mdd": "{:.2%}".format, "neighbor_median": "{:+.2%}".format, "neighbor_std": "{:.2%}".format, "neighbor_min": "{:+.2%}".format}))
print(f"전체 변수 조합: {len(results)}개")
print(f"결과 저장: {RESULT_FILE}")
print(f"후보 거래 저장: {TRADE_FILE}")
print(f"차트 저장: {CHART_FILE}")
select_candidates()는 선택 구간 결과만 받습니다. 이 함수에서 두 후보가 확정된 뒤 결과표의 검증 구간 열을 비교합니다. 코드를 수정할 때도 검증 수익률을 평탄 점수에 넣으면 안 됩니다. 그렇게 하면 남겨둔 데이터가 다시 최적화 데이터로 바뀝니다.
5. 최고점은 무너지지 않았지만 1위도 아니었다
작성 환경에서 실행한 후보 비교 결과입니다.
| 후보 | EMA | 선택 수익률 | 선택 MDD | 검증 수익률 | 검증 MDD | 검증 순위 |
|---|---|---|---|---|---|---|
| 최고점 | 15·60 | +248.90% | -17.02% | +89.85% | -16.87% | 13위 |
| 평탄 구간 | 35·120 | +224.11% | -26.23% | +79.97% | -22.57% | 41위 |

빨간 테두리가 선택 구간 최고점이고 파란 테두리가 평탄 구간 후보입니다. 오른쪽 히트맵에서도 같은 위치를 표시했습니다.
최고점 후보는 검증 구간에서 수익률이 +89.85%로 줄었지만 MDD는 -16.87%로 거의 같았습니다. 이번 표본에서는 최고 수익률을 골랐더니 바로 손실로 무너졌다고 말할 수 없습니다. 오히려 두 후보 중 검증 수익률과 MDD가 모두 나았습니다.
그렇다고 선택 구간의 1위를 미래의 1위로 해석할 수도 없습니다. 검증 구간 최고 수익률은 EMA 10·80의 +97.90%였고, EMA 15·60은 13위였습니다. 선택 구간 1위라는 순위는 유지되지 않았습니다.
6. 평탄 구간도 자동 정답은 아니었다
두 후보 주변의 모양은 분명히 달랐습니다.
| 후보 | 이웃 조합 수 | 주변 중앙값 | 주변 표준편차 | 주변 최저 수익률 |
|---|---|---|---|---|
| EMA 15·60 최고점 | 6개 | +220.46% | 30.63%p | +167.37% |
| EMA 35·120 평탄 후보 | 9개 | +204.36% | 10.11%p | +189.92% |
EMA 35·120 주변은 최고점 주변보다 편차가 작고 최저 수익률도 높았습니다. 특정 한 칸만 솟은 모양이 아니라 비슷한 값들이 모인 구간입니다.
그러나 남겨둔 검증 구간에서는 이 후보가 +79.97%, MDD -22.57%로 최고점 후보보다 뒤처졌습니다. 거래 수가 적고 Profit Factor가 높았지만 최종 수익률과 낙폭을 함께 보면 더 낫다고 결론 내리기 어렵습니다.
평탄성은 과거 결과가 작은 변수 변화에 민감한지 보여주는 진단입니다. 미래 성과를 보장하는 점수가 아닙니다. 최고점은 위험하니 평탄 점수 1위를 고른다는 새로운 단일 기준을 만들면 최적화 기준만 바뀔 뿐입니다.
7. 선택 구간 순위와 검증 순위가 거의 연결되지 않았다
49개 조합의 선택 구간 누적수익률은 +166.57%에서 +248.90%였습니다. 검증 구간은 +76.97%에서 +97.90%였습니다. 모든 조합이 플러스였지만 높은 순서가 크게 바뀌었습니다.
선택 구간 수익률 순위와 검증 구간 수익률 순위의 스피어만 상관계수는 -0.10이었습니다. 1에 가까우면 과거 순위가 비슷하게 유지되고, 0에 가까우면 순위 관계가 약합니다. 이번 결과는 선택 구간에서 더 높았던 조합이 검증 구간에서도 더 높을 것이라는 근거가 거의 없었다는 뜻입니다.
이 부분이 최고점 후보의 검증 수익률 +89.85%보다 중요합니다. 한 후보가 수익으로 살아남았더라도 그 후보를 1위로 골라낸 최적화 순위까지 검증된 것은 아닙니다.
누적수익률은 비슷한데 낙폭이 달랐던 성과표 실험처럼 변수 선택에서도 수익률 하나만 보지 않아야 합니다. MDD, 거래 수, Profit Factor, 노출 비중과 주변 민감도를 함께 놓아야 후보의 성격이 보입니다.
8. 실제 변수 선택은 세 단계를 통과시킨다
첫째, 경제적으로 설명되는 범위만 시험한다
EMA 기간을 1일부터 500일까지 무작정 돌리면 우연히 좋은 조합을 찾을 기회만 늘어납니다. 빠른 선은 단기 추세, 느린 선은 중기 추세라는 역할이 유지되는 범위에서 적은 수의 조합을 시험합니다.
둘째, 최고점과 주변 모양을 함께 본다
최고점이 넓고 높은 구간 안에 있는지, 외딴 한 칸인지 확인합니다. 변수를 한 칸 바꿨을 때 수익률이나 MDD가 급변한다면 실제 체결 오차와 시장 변화에도 민감할 가능성이 있습니다.
셋째, 남겨둔 구간은 마지막에 한 번만 연다
검증 결과를 본 뒤 변수를 다시 고치면 그 구간도 선택 데이터가 됩니다. 수정이 필요하다면 새로운 검증 구간을 준비하거나 다음 전진분석 단계로 넘어가야 합니다.
이 세 단계를 통과해도 실전 투입의 증명은 아닙니다. 거래비용, 슬리피지, 생존 편향, 주문 가능 금액과 실시간 신호 차이를 별도로 확인해야 합니다.
9. 이번 실험의 한계
첫째, 한 번의 고정 분할만 사용했습니다. 2014~2021년과 2022~2025년의 경계를 바꾸면 후보와 결과가 달라질 수 있습니다.
둘째, 현재 확인 가능한 30종목을 사용해 상장폐지 종목이 빠진 생존 편향이 남아 있습니다. yfinance 수정주가도 실제 증권사 원시 체결 데이터와 다릅니다.
셋째, 비용은 매수·매도 각각 0.05%로 단순화했습니다. 세금, 호가 스프레드, 시장 충격과 주문 실패를 완전히 재현하지 않았습니다.
넷째, 평탄 점수는 누적수익률 중앙값과 표준편차만 사용했습니다. MDD와 거래 수까지 포함한 다목적 기준을 사용하면 다른 후보가 선택될 수 있습니다.
다섯째, 49개 조합 모두 검증 구간에서 수익이었습니다. 이는 EMA 전략이 모든 미래 구간에서 수익이라는 뜻이 아니라, 이 종목군과 이 기간에 공통적인 추세가 존재했다는 결과입니다.
10. 정리
이번 변수 안정성 실험에서 확인한 사실은 세 가지입니다.
- 선택 구간 최고점 EMA 15·60은 검증 구간에서도 +89.85%였지만 순위는 1위에서 13위로 내려갔습니다.
- EMA 35·120 주변은 더 평탄했지만 검증 수익률과 MDD가 최고점 후보보다 낫지는 않았습니다.
- 49개 조합의 선택·검증 순위 상관은 -0.10으로, 과거 최적화 순위의 재현성이 낮았습니다.
따라서 최고 수익률을 무조건 버릴 필요도, 평탄 점수 1위를 새 정답으로 삼을 필요도 없습니다. 변수의 논리적인 범위를 먼저 정하고, 주변 민감도를 확인하고, 선택에 쓰지 않은 데이터에서 성과와 위험을 다시 평가해야 합니다.
다음 글에서는 한 번의 고정 분할을 여러 번 반복합니다. 과거 구간을 계속 늘리는 확장형과 일정 길이만 이동하는 롤링형 전진분석에서 선택되는 EMA 기간과 표본외 성과가 어떻게 달라지는지 비교합니다.
이 글은 과거 데이터를 이용한 백테스트 예제이며 특정 종목이나 전략의 미래 수익을 보장하지 않습니다. 실제 투자에서는 세금, 수수료, 슬리피지, 체결 가능성과 계좌 위험을 별도로 검토해야 합니다.