CCI가 -100 아래로 내려가면 가격이 평균에서 크게 벗어난 과매도 구간으로 해석합니다. 이때 가격이 싸 보인다는 이유로 바로 매수하면 하락 중인 종목을 계속 받아내는 거래가 되기 쉽습니다.
조금 더 기다려 CCI가 -100을 다시 상향 돌파할 때 진입하면 반등을 확인할 수 있습니다. 하지만 과매도 구간을 벗어났다는 사실만으로 장기 하락 추세가 끝났다고 볼 수는 없습니다.
국내 주식 30종목에서 이 규칙을 검증하자 CCI 단독 전략은 2,454번 거래했고, 그중 685번이 8% 손절 조건에 걸렸습니다. 200일 EMA 추세와 ATR 변동성 조건을 추가하자 손절은 201번으로 줄었지만 누적수익률도 +56.87%에서 +8.88%로 낮아졌습니다.
필터는 위험한 하락 구간만 제거하지 않았습니다. 변동성이 큰 반등과 수익 거래까지 함께 건너뛰었습니다.
SYSTEM LAB 08 핵심 결과
- 전체 거래 2,454건 → 838건
- 8% 손절 685건 → 201건
- 손절 비율 27.91% → 23.99%
- MDD -19.75% → -11.23%
- 누적수익률 +56.87% → +8.88%
1. CCI -100은 바닥 가격이 아니다
CCI(Commodity Channel Index)는 당일 전형가격이 일정 기간 평균에서 얼마나 떨어져 있는지 평균편차로 표준화합니다.
전형가격 = (고가 + 저가 + 종가) / 3
CCI = (전형가격 - 전형가격의 이동평균) / (0.015 × 평균편차)
CCI가 -100보다 낮으면 최근 평균에 비해 가격이 아래로 크게 벗어났다는 뜻입니다. 다만 이 값에는 장기 추세가 끝났다는 정보가 없습니다. 강한 하락장에서는 평균도 계속 내려가므로 CCI가 -100을 여러 번 오갈 수 있습니다.
이번 진입 규칙은 CCI가 -100 아래에 있다는 이유만으로 매수하지 않습니다. CCI(20)가 -100을 다시 상향 돌파해 과매도 구간에서 빠져나오는 날을 진입 후보로 잡습니다.
CCI 과매도와 우물 파기 패턴을 함께 보면 지표의 계산 구조와 신호를 먼저 이해할 수 있습니다. 이번 글은 그 신호를 실제 매매 규칙으로 사용했을 때 무엇이 부족한지 검증하는 단계입니다.
2. 추세와 변동성이라는 두 개의 안전장치
필터 전략은 CCI 진입 후보에 두 조건을 추가합니다.
첫째, 종가가 200일 EMA보다 높고 200일 EMA도 20거래일 전보다 높아야 합니다. 장기 상승 추세 안에서 발생한 단기 과매도만 거래하기 위한 조건입니다.
둘째, ATR(14)을 종가로 나눈 값이 5% 이하여야 합니다. 하루 변동 폭이 가격의 5%를 넘는 급격한 구간을 피하기 위한 조건입니다.
| 상태 | 해석 | 진입 여부 |
|---|---|---|
| 종가 > 200일 EMA, EMA 상승, ATR% ≤ 5% | 상승 추세 안의 통제 가능한 조정 | 진입 후보 |
| 종가 < 200일 EMA | 장기 하락 중의 일시 반등 가능성 | 건너뜀 |
| 200일 EMA 하락 | 가격이 평균 위여도 장기 추세 회복 전 | 건너뜀 |
| ATR% > 5% | 갭과 급등락 위험이 큰 구간 | 건너뜀 |
200일과 5%가 최적값이라는 뜻은 아닙니다. 결과를 본 뒤 가장 좋아 보이는 숫자를 고른 것이 아니라 장기 추세와 과도한 변동성을 분리하기 위한 고정 기준입니다.
3. 진입보다 청산을 먼저 고정한다
CCI 반등 신호를 비교하면서 청산까지 다르게 만들면 필터의 효과를 구분하기 어렵습니다. 두 전략은 같은 청산과 체결 조건을 사용합니다.
| 구분 | CCI 단독 | 추세·변동성 필터 |
|---|---|---|
| 데이터 | yfinance 수정주가 일봉 | 동일 |
| 대상 | 국내 주식 30종목 | 동일 |
| 진입 후보 | CCI(20)가 -100 상향 돌파 | 동일 |
| 추가 조건 | 없음 | 장기 상승 추세, ATR% ≤ 5% |
| 이익 청산 | CCI(20)가 +100 상향 돌파 | 동일 |
| 손절 | 종가 기준 -8% 도달 | 동일 |
| 시간 청산 | 최대 20거래일 보유 | 동일 |
| 체결 | 종가 신호 다음 거래일 시가 | 동일 |
| 비용 | 매수·매도 각각 0.05% | 동일 |
| 중복 진입 | 보유 중 추가 진입 금지 | 동일 |
손절 조건도 종가로 확인한 뒤 다음 거래일 시가에 주문합니다. 따라서 손실이 정확히 -8%에서 멈춘다고 가정하지 않습니다. 갭이나 거래 공백이 있으면 실제 백테스트 체결 손실은 더 커질 수 있습니다.
이 체결 차이는 종가 신호를 다음 시가에 체결해야 하는 이유와 같은 원칙입니다.
4. 전체 Python 코드
필요한 패키지는 pandas, numpy, matplotlib, yfinance입니다. 아래 코드는 데이터 수집부터 CCI·EMA·ATR 계산, 포지션 상태, 다음 시가 체결, 거래 내역과 결과 차트까지 한 번에 실행합니다.
#!/usr/bin/env python3
"""CCI 역추세 진입에 추세·변동성 필터를 추가한 결과를 비교한다."""
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"
TEST_START = "2014-01-01"
TEST_END = "2026-01-01"
CCI_WINDOW = 20
EMA_WINDOW = 200
EMA_SLOPE_DAYS = 20
ATR_WINDOW = 14
MAX_ATR_PERCENT = 5.0
STOP_LOSS = 0.08
MAX_HOLDING_DAYS = 20
COST_RATE = 0.0005
MIN_ROWS = 252
CHART_FILE = Path("cci_filter_chart.png")
RESULT_FILE = Path("cci_filter_results.csv")
TRADE_FILE = Path("cci_filter_trades.csv")
def download_prices() -> dict[str, pd.DataFrame]:
prices = {}
for ticker in TICKERS:
frame = pd.DataFrame()
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)
frame = retry.rename(columns=str.lower).dropna(subset=["open", "high", "low", "close"]).copy()
if len(frame.loc[(frame.index >= TEST_START) & (frame.index < TEST_END)]) >= MIN_ROWS:
break
time.sleep(1.0 + attempt)
if len(frame.loc[(frame.index >= TEST_START) & (frame.index < TEST_END)]) < MIN_ROWS:
raise RuntimeError(f"검증에 필요한 가격 데이터가 부족합니다: {ticker}")
prices[ticker] = frame
return prices
def mean_absolute_deviation(values: np.ndarray) -> float:
return float(np.mean(np.abs(values - values.mean())))
def prepare_price(prices: dict[str, pd.DataFrame], ticker: str) -> pd.DataFrame:
data = prices[ticker].copy()
typical_price = (data["high"] + data["low"] + data["close"]) / 3.0
typical_average = typical_price.rolling(CCI_WINDOW, min_periods=CCI_WINDOW).mean()
mean_deviation = typical_price.rolling(CCI_WINDOW, min_periods=CCI_WINDOW).apply(mean_absolute_deviation, raw=True)
data["cci"] = (typical_price - typical_average) / (0.015 * mean_deviation)
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)
data["atr"] = true_range.ewm(alpha=1.0 / ATR_WINDOW, adjust=False, min_periods=ATR_WINDOW).mean()
data["atr_percent"] = data["atr"] / data["close"] * 100.0
data["ema_long"] = data["close"].ewm(span=EMA_WINDOW, adjust=False, min_periods=EMA_WINDOW).mean()
data["trend_filter"] = (data["close"] > data["ema_long"]) & (data["ema_long"] > data["ema_long"].shift(EMA_SLOPE_DAYS))
data["volatility_filter"] = data["atr_percent"] <= MAX_ATR_PERCENT
data["cci_entry_signal"] = (data["cci"] > -100.0) & (data["cci"].shift(1) <= -100.0)
data["filtered_entry_signal"] = data["cci_entry_signal"] & data["trend_filter"] & data["volatility_filter"]
data["cci_exit_signal"] = (data["cci"] > 100.0) & (data["cci"].shift(1) <= 100.0)
return data.loc[(data.index >= TEST_START) & (data.index < TEST_END)].copy()
def run_backtest(data: pd.DataFrame, entry_column: str) -> tuple[pd.DataFrame, pd.DataFrame]:
cash = 1.0
shares = 0.0
pending_order = None
current_trade = None
trades = []
daily_records = []
for index, (date, row) in enumerate(data.iterrows()):
if pending_order is not None:
if pending_order["side"] == "buy" and shares == 0.0:
capital_before = cash
entry_price = float(row["open"])
shares = cash * (1.0 - COST_RATE) / entry_price
cash = 0.0
current_trade = {"entry_signal_date": pending_order["signal_date"], "entry_date": date, "entry_index": index, "entry_price": entry_price, "capital_before": capital_before, "entry_cci": pending_order["entry_cci"], "entry_atr_percent": pending_order["entry_atr_percent"], "trend_pass": pending_order["trend_pass"], "volatility_pass": pending_order["volatility_pass"], "minimum_close": entry_price}
elif pending_order["side"] == "sell" and shares > 0.0:
exit_price = float(row["open"])
cash = shares * exit_price * (1.0 - COST_RATE)
shares = 0.0
trade_return = cash / current_trade["capital_before"] - 1.0
holding_days = index - current_trade["entry_index"]
maximum_adverse_excursion = current_trade["minimum_close"] / current_trade["entry_price"] - 1.0
trades.append({**current_trade, "exit_signal_date": pending_order["signal_date"], "exit_date": date, "exit_price": exit_price, "trade_return": trade_return, "holding_days": holding_days, "maximum_adverse_excursion": maximum_adverse_excursion, "exit_reason": pending_order["reason"], "forced_exit": False})
current_trade = None
pending_order = None
if shares > 0.0:
current_trade["minimum_close"] = min(current_trade["minimum_close"], float(row["close"]))
holding_days = index - current_trade["entry_index"]
close_return = float(row["close"]) / current_trade["entry_price"] - 1.0
if close_return <= -STOP_LOSS:
pending_order = {"side": "sell", "signal_date": date, "reason": "손절"}
elif bool(row["cci_exit_signal"]):
pending_order = {"side": "sell", "signal_date": date, "reason": "CCI 청산"}
elif holding_days >= MAX_HOLDING_DAYS:
pending_order = {"side": "sell", "signal_date": date, "reason": "시간 청산"}
elif bool(row[entry_column]):
pending_order = {"side": "buy", "signal_date": date, "entry_cci": float(row["cci"]), "entry_atr_percent": float(row["atr_percent"]), "trend_pass": bool(row["trend_filter"]), "volatility_pass": bool(row["volatility_filter"])}
daily_records.append({"date": date, "equity": cash + shares * float(row["close"]), "exposure": float(shares > 0.0)})
if shares > 0.0:
last_index = len(data) - 1
last_date = data.index[-1]
last_close = float(data.iloc[-1]["close"])
cash = shares * last_close * (1.0 - COST_RATE)
trade_return = cash / current_trade["capital_before"] - 1.0
holding_days = last_index - current_trade["entry_index"]
maximum_adverse_excursion = min(current_trade["minimum_close"], last_close) / current_trade["entry_price"] - 1.0
trades.append({**current_trade, "exit_signal_date": last_date, "exit_date": last_date, "exit_price": last_close, "trade_return": trade_return, "holding_days": holding_days, "maximum_adverse_excursion": maximum_adverse_excursion, "exit_reason": "기간 종료", "forced_exit": True})
daily_records[-1]["equity"] = cash
daily_records[-1]["exposure"] = 0.0
return pd.DataFrame(trades), pd.DataFrame(daily_records).set_index("date")
def summarize(trades: pd.DataFrame, portfolio: pd.DataFrame) -> dict[str, float]:
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"]
return {"누적수익률": float(portfolio["equity"].iloc[-1] - 1.0), "MDD": float(drawdown.min()), "Profit Factor": float(winners.sum() / losers.abs().sum()), "승률": float((trades["trade_return"] > 0.0).mean()), "거래수": len(trades), "손절수": int((trades["exit_reason"] == "손절").sum()), "손절비율": float((trades["exit_reason"] == "손절").mean()), "중앙거래수익률": float(trades["trade_return"].median()), "평균MAE": float(trades["maximum_adverse_excursion"].mean()), "노출비중": float(portfolio["exposure"].mean())}
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(summary: pd.DataFrame, all_trades: dict[str, pd.DataFrame], prepared: dict[str, pd.DataFrame]) -> None:
colors = {"CCI 단독": "#F59E0B", "추세·변동성 필터": "#0F766E"}
rejected = all_trades["CCI 단독"].loc[~all_trades["CCI 단독"]["trend_pass"] | ~all_trades["CCI 단독"]["volatility_pass"]]
worst = rejected.sort_values("trade_return").iloc[0]
example = prepared[worst["ticker"]]
start_position = max(0, example.index.get_loc(pd.Timestamp(worst["entry_date"])) - 35)
end_position = min(len(example), example.index.get_loc(pd.Timestamp(worst["exit_date"])) + 21)
window = example.iloc[start_position:end_position]
fig = plt.figure(figsize=(13, 9), facecolor="white")
grid = fig.add_gridspec(2, 2, width_ratios=[1.55, 1.0], height_ratios=[1.45, 1.0], hspace=0.32, wspace=0.25)
price_axis = fig.add_subplot(grid[0, 0])
cci_axis = fig.add_subplot(grid[1, 0], sharex=price_axis)
distribution_axis = fig.add_subplot(grid[0, 1])
reason_axis = fig.add_subplot(grid[1, 1])
price_axis.plot(window.index, window["close"], color="#14213D", linewidth=2.2, label="종가")
price_axis.plot(window.index, window["ema_long"], color="#64748B", linewidth=1.8, linestyle="--", label="200일 EMA")
price_axis.scatter(pd.Timestamp(worst["entry_date"]), worst["entry_price"], s=110, color=AMBER, edgecolor="white", linewidth=1.3, zorder=5, label="CCI 단독 진입")
price_axis.scatter(pd.Timestamp(worst["exit_date"]), worst["exit_price"], s=110, color="#B91C1C", marker="X", edgecolor="white", linewidth=1.0, zorder=5, label=f"{worst['exit_reason']} {worst['trade_return']:+.1%}")
price_axis.set_title(f"필터가 막은 하락 지속 사례 · {worst['ticker']}", fontsize=16, fontweight="bold")
price_axis.set_ylabel("수정주가")
price_axis.legend(frameon=False, fontsize=9)
cci_axis.plot(window.index, window["cci"], color="#7C3AED", linewidth=1.8)
cci_axis.axhline(-100.0, color=AMBER, linestyle="--", linewidth=1.4)
cci_axis.axhline(100.0, color="#0F766E", linestyle="--", linewidth=1.2)
cci_axis.fill_between(window.index, window["cci"], -100.0, where=window["cci"] < -100.0, color="#FDE68A", alpha=0.55)
cci_axis.set_title("CCI 과매도 탈출 뒤에도 가격은 더 내려갈 수 있다", fontsize=13, fontweight="bold")
cci_axis.set_ylabel("CCI(20)")
labels = list(all_trades)
distributions = [all_trades[label]["trade_return"].clip(-0.2, 0.2) * 100.0 for label in labels]
box = distribution_axis.boxplot(distributions, tick_labels=labels, patch_artist=True, showfliers=False, widths=0.55)
for patch, label in zip(box["boxes"], labels):
patch.set_facecolor(colors[label])
patch.set_alpha(0.72)
distribution_axis.axhline(0.0, color="#64748B", linewidth=1.1)
distribution_axis.set_title("전체 거래 수익률 분포", fontsize=15, fontweight="bold")
distribution_axis.set_ylabel("거래 수익률(%, -20~+20 표시)")
reason_table = pd.DataFrame({label: all_trades[label]["exit_reason"].value_counts(normalize=True) for label in labels}).fillna(0.0).T
reason_order = [reason for reason in ("CCI 청산", "시간 청산", "손절", "기간 종료") if reason in reason_table.columns]
reason_colors = {"CCI 청산": "#0F766E", "시간 청산": "#94A3B8", "손절": "#B91C1C", "기간 종료": "#F59E0B"}
left = np.zeros(len(labels))
for reason in reason_order:
values = reason_table[reason].to_numpy() * 100.0
reason_axis.barh(labels, values, left=left, color=reason_colors[reason], label=reason)
left += values
reason_axis.set_title("청산 사유 구성", fontsize=15, fontweight="bold")
reason_axis.set_xlabel("거래 비중(%)")
reason_axis.legend(frameon=False, fontsize=9, loc="lower center", bbox_to_anchor=(0.5, -0.42), ncol=2)
fig.suptitle("CCI 과매도 신호는 추세와 변동성을 확인해야 하락 지속 구간을 피할 수 있다", fontsize=20, fontweight="bold", y=0.99)
for axis in (price_axis, cci_axis, distribution_axis, reason_axis):
axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
axis.spines["top"].set_visible(False)
axis.spines["right"].set_visible(False)
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()
AMBER = "#F59E0B"
prices = download_prices()
prepared = {ticker: prepare_price(prices, ticker) for ticker in TICKERS}
calendar = pd.DatetimeIndex(sorted(set().union(*(data.index for data in prepared.values()))))
trade_frames = {"CCI 단독": [], "추세·변동성 필터": []}
equity_series = {"CCI 단독": [], "추세·변동성 필터": []}
exposure_series = {"CCI 단독": [], "추세·변동성 필터": []}
for ticker, data in prepared.items():
if len(data) < MIN_ROWS or data.dropna(subset=["cci", "ema_long", "atr_percent"]).empty:
continue
baseline_trades, baseline_daily = run_backtest(data, "cci_entry_signal")
filtered_trades, filtered_daily = run_backtest(data, "filtered_entry_signal")
for label, trades, daily in (("CCI 단독", baseline_trades, baseline_daily), ("추세·변동성 필터", filtered_trades, filtered_daily)):
trades.insert(0, "ticker", ticker)
trade_frames[label].append(trades)
equity_series[label].append(daily["equity"].reindex(calendar).ffill().fillna(1.0).rename(ticker))
exposure_series[label].append(daily["exposure"].reindex(calendar).ffill().fillna(0.0).rename(ticker))
portfolios = {}
all_trades = {}
summary_rows = {}
for label in trade_frames:
all_trades[label] = pd.concat(trade_frames[label], ignore_index=True)
portfolios[label] = pd.DataFrame({"equity": pd.concat(equity_series[label], axis=1).mean(axis=1), "exposure": pd.concat(exposure_series[label], axis=1).mean(axis=1)})
summary_rows[label] = summarize(all_trades[label], portfolios[label])
summary = pd.DataFrame(summary_rows).T
summary[["거래수", "손절수"]] = summary[["거래수", "손절수"]].astype(int)
tagged_trades = pd.concat([trades.assign(strategy=label) for label, trades in all_trades.items()], ignore_index=True)
summary.to_csv(RESULT_FILE, encoding="utf-8-sig")
tagged_trades.to_csv(TRADE_FILE, index=False, encoding="utf-8-sig")
draw_chart(summary, all_trades, prepared)
rejected = all_trades["CCI 단독"].loc[~all_trades["CCI 단독"]["trend_pass"] | ~all_trades["CCI 단독"]["volatility_pass"]]
print(summary.to_string(formatters={"누적수익률": "{:+.2%}".format, "MDD": "{:.2%}".format, "Profit Factor": "{:.2f}".format, "승률": "{:.2%}".format, "손절비율": "{:.2%}".format, "중앙거래수익률": "{:+.2%}".format, "평균MAE": "{:.2%}".format, "노출비중": "{:.2%}".format}))
print(f"CCI 단독 거래 중 필터 조건 미충족: {len(rejected)}건, 손실 {int((rejected['trade_return'] < 0.0).sum())}건, 수익 {int((rejected['trade_return'] > 0.0).sum())}건")
print(f"미충족 거래 중 8% 손절: {int((rejected['exit_reason'] == '손절').sum())}건")
print(f"결과 저장: {RESULT_FILE}")
print(f"거래 저장: {TRADE_FILE}")
print(f"차트 저장: {CHART_FILE}")
prepare_price()는 CCI 진입 후보와 두 필터를 각각 Boolean 열로 만듭니다. 필터를 하나의 조건으로 합치기 전에 trend_filter와 volatility_filter를 나눠 기록해 어떤 이유로 거래가 제외됐는지 확인할 수 있게 했습니다.
run_backtest()는 보유 중 추가 진입을 막고 손절, CCI 청산, 시간 청산 순서로 조건을 검사합니다. 손절 신호가 발생한 날의 종가가 아니라 다음 거래일 시가를 사용하므로 갭 위험이 거래 내역에 남습니다.
5. 손절은 줄었지만 수익률도 함께 줄었다
작성 환경에서 실행한 결과입니다.
| 지표 | CCI 단독 | 추세·변동성 필터 | 변화 |
|---|---|---|---|
| 누적수익률 | +56.87% | +8.88% | -47.99%p |
| MDD | -19.75% | -11.23% | +8.52%p 개선 |
| Profit Factor | 1.18 | 1.15 | -0.03 |
| 승률 | 59.54% | 62.89% | +3.35%p |
| 거래 수 | 2,454건 | 838건 | -1,616건 |
| 손절 수 | 685건 | 201건 | -484건 |
| 손절 비율 | 27.91% | 23.99% | -3.92%p |
| 중앙 거래 수익률 | +2.03% | +1.99% | -0.04%p |
| 평균 최대 불리 움직임 | -4.64% | -4.28% | +0.36%p 개선 |
| 노출 비중 | 31.02% | 10.33% | -20.69%p |
필터 전략은 거래 수를 65.85% 줄였습니다. 손절 수도 70.66% 감소했고, 전체 거래에서 손절이 차지하는 비율은 3.92%p 낮아졌습니다.
그러나 Profit Factor는 1.18에서 1.15로 오히려 조금 낮아졌고 중앙 거래 수익률도 거의 달라지지 않았습니다. 거래를 크게 줄인 것에 비해 개별 거래의 품질 개선은 제한적이었습니다.
MDD가 8.52%p 개선된 데에는 시장 노출이 31.02%에서 10.33%로 줄어든 영향이 큽니다. 필터 전략은 대부분의 기간을 현금으로 보냈기 때문에 손실 기회와 수익 기회가 함께 줄었습니다.
6. 자산곡선보다 실패 구간과 분포가 더 중요하다
이번 질문은 어떤 전략의 최종 자산이 더 높았는지가 전부가 아닙니다. CCI 과매도 탈출이 어떤 구간에서 실패하고 필터가 그 거래를 실제로 막았는지가 핵심입니다.
그래서 아래 차트는 자산곡선을 반복하지 않고 네 가지 근거를 함께 배치했습니다.
- 필터가 제외한 거래 중 손실이 가장 컸던 가격 구간
- 같은 구간의 CCI 움직임
- 두 전략의 전체 거래 수익률 분포
- CCI 청산, 시간 청산과 손절의 구성

왼쪽 사례에서 CCI는 -100을 상향 돌파했지만 가격은 200일 EMA 아래에 있었고 ATR 비율도 6.33%였습니다. CCI 단독 전략은 다음 시가에 진입했지만 이후 하락이 이어졌습니다.
종가 손실이 -8%에 도달해 손절 신호가 나왔어도 다음으로 관측된 시가의 체결 손실은 -26.88%였습니다. 백테스트 코드에 손절 숫자를 적어놓는 것과 그 가격에 실제로 나갈 수 있는 것은 다른 문제입니다.
오른쪽 수익률 분포를 보면 필터 전략의 중앙값이 CCI 단독보다 크게 좋아지지 않았습니다. 필터가 왼쪽 같은 극단 손실을 제거한 효과는 있었지만, 수익 분포 전체를 위로 이동시키지는 못했습니다.
7. 필터가 건너뛴 거래에도 수익은 많았다
CCI 단독 전략의 실제 거래 가운데 추세 또는 변동성 조건을 만족하지 못한 거래는 1,636건이었습니다.
| 필터 조건을 만족하지 못한 CCI 단독 거래 | 건수 |
|---|---|
| 전체 | 1,636건 |
| 손실 거래 | 688건 |
| 수익 거래 | 948건 |
| 8% 손절 | 488건 |
필터는 488건의 손절 거래를 건너뛰었지만 수익 거래도 948건 제외했습니다. 특히 변동성이 높은 반등은 손실 위험이 큰 동시에 반등 폭도 클 수 있습니다.
그래서 승률과 MDD는 개선됐지만 누적수익률은 47.99%p 낮아졌습니다. 역추세 전략에서 변동성을 제한하면 큰 낙폭을 피할 수 있는 대신, 급락 뒤 강한 되돌림도 포기하게 됩니다.
이 결과는 필터가 잘못됐다는 뜻보다 필터의 목적을 분명하게 정해야 한다는 뜻입니다. 계좌 낙폭과 주문 위험을 줄이는 목적에는 도움이 됐지만, 수익 거래를 선별하는 능력은 충분하지 않았습니다.
8. 물타기와 역추세 전략은 다르다
CCI가 다시 과매도에 들어갈 때마다 수량을 추가하면 평균 매입 단가는 낮아집니다. 하지만 손실 한도는 커지고 한 종목이 계좌에서 차지하는 비중도 계속 늘어납니다.
이번 코드는 보유 중 추가 진입을 금지합니다. 같은 신호가 다시 나와도 기존 포지션을 늘리지 않습니다. 진입 전에 손절과 시간 청산을 정해두고, 청산된 뒤에만 다음 거래를 검토합니다.
| 물타기 | 규칙 기반 역추세 전략 |
|---|---|
| 가격이 내릴수록 수량 추가 | 보유 중 추가 진입 금지 |
| 손실 한도가 뒤로 밀림 | 진입 전에 손절 고정 |
| 하락 추세도 계속 매수 | 추세·변동성 조건으로 거래 제한 |
| 회복할 때까지 무기한 보유 | 최대 보유 기간 설정 |
과매도는 포지션을 늘릴 근거가 아닙니다. 평균에서 멀어진 가격이 평균으로 돌아올 가능성과 하락 추세가 계속될 위험을 함께 다뤄야 합니다.
9. 이번 실험의 한계
첫째, CCI 20일, 200일 EMA, ATR 5%, 8% 손절과 20거래일 보유 기간은 고정한 예시입니다. 결과가 가장 좋아지는 조합을 찾은 것이 아닙니다.
둘째, 일봉 종가로 손절을 확인해 다음 시가에 청산했습니다. 장중 지정가·시장가 주문, 거래정지, 가격제한폭과 주문 실패를 세밀하게 재현하지 못합니다.
셋째, 선정한 대형·유동성 종목 30개를 사용해 상장폐지 종목이 빠진 생존 편향이 남아 있습니다.
넷째, yfinance 수정주가는 제공처의 과거 데이터 보정에 따라 세부 결과가 달라질 수 있습니다. 실제 세금과 호가 스프레드, 시장 충격도 모두 반영하지 못했습니다.
다섯째, 필터 두 개를 동시에 추가했습니다. 다음 단계에서는 추세 조건과 ATR 조건을 따로 제거해 어느 조건이 손절과 수익 거래에 더 큰 영향을 주었는지 분리해야 합니다.
10. 정리
이번 CCI 역추세 실험에서 확인한 사실은 세 가지입니다.
- 추세·변동성 필터를 추가하자 손절은 685건에서 201건, MDD는 -19.75%에서 -11.23%로 줄었습니다.
- 거래와 시장 노출이 크게 감소하면서 누적수익률도 +56.87%에서 +8.88%로 낮아졌습니다.
- CCI가 -100을 벗어나도 하락이 끝났다는 보장은 없으며, 종가 손절은 갭이 발생하면 정한 손실률에 체결되지 않습니다.
CCI 과매도 신호를 사용할 때는 값이 낮다는 사실보다 장기 추세, 변동성, 최대 보유 기간과 실제 청산 가능성을 먼저 고정해야 합니다.
다음 글에서는 같은 돌파 전략이라도 과거 고가를 기준으로 한 가격 채널과 ATR을 기준으로 한 변동성 채널의 결과가 왜 달라지는지 비교합니다.
이 글은 과거 데이터를 이용한 백테스트 예제이며 특정 종목이나 전략의 미래 수익을 보장하지 않습니다. 실제 투자에서는 세금, 수수료, 슬리피지, 체결 가능성과 계좌 위험을 별도로 검토해야 합니다.