같은 돌파인데 거래가 325번 줄었다, 돈치안·ATR 채널 비교

고점을 돌파하면 매수한다는 규칙은 단순합니다. 문제는 어떤 고점을 돌파선으로 사용할지 정하는 순간부터 시작됩니다.

직전 20거래일 최고가를 기준으로 삼을 수도 있고, 최근 평균 가격에 ATR을 더해 시장 변동성에 따라 움직이는 선을 만들 수도 있습니다. 둘 다 상승 돌파 전략이지만 같은 날 같은 신호를 만들지는 않습니다.

국내 주식 30종목에 두 규칙을 적용한 결과, 돈치안 가격 채널은 1,277회, ATR 변동성 채널은 952회 거래했습니다. ATR 채널은 거래를 325회 줄이고 Profit Factor를 1.79에서 1.92로 높였지만 누적수익률은 182.05%에서 160.64%로 낮아졌습니다.

SYSTEM LAB 09 핵심 결과

  • 돈치안 가격 채널: 누적수익률 +182.05%, 거래 1,277회
  • ATR 변동성 채널: 누적수익률 +160.64%, 거래 952회
  • 거래 수 325회 감소, 노출 비중 35.85% → 27.71%
  • Profit Factor 1.79 → 1.92, 승률 39.70% → 40.55%
  • MDD -17.78%와 -17.76%로 사실상 비슷

1. 돈치안 채널과 ATR 채널은 무엇을 기억하는가

돈치안 가격 채널은 최근 가격의 극값을 기억합니다. 이번 실험에서는 계산하는 거래일을 제외한 직전 20거래일 고가 중 가장 높은 값을 상단선으로 사용합니다.

새로운 고가가 나오기 전까지 상단선은 계단처럼 같은 자리에 머뭅니다. 오래된 최고가가 20일 범위 밖으로 빠지거나 더 높은 가격이 나오면 선이 움직입니다.

ATR 변동성 채널은 최근 가격의 중심과 흔들림을 함께 봅니다.

TEXT
ATR 상단 채널 = 20일 EMA + 2 × ATR(14)

가격 변동이 커지면 ATR이 증가해 상단선이 멀어지고, 변동이 작아지면 선이 가격 가까이 좁아집니다. 고정된 과거 고점이 아니라 현재 변동성에 맞춰 통과 기준을 조정하는 구조입니다.

돈치안 채널의 기본 계산과 터틀 트레이딩, ATR이 측정하는 실제 가격 변동폭을 먼저 보면 두 채널의 차이를 더 쉽게 이해할 수 있습니다.

2. 진입 채널만 바꾸고 나머지는 고정한다

두 전략의 차이를 진입 채널로 해석하려면 청산과 체결 조건을 함께 바꾸면 안 됩니다. 이번 실험에서는 아래 조건을 공통으로 적용했습니다.

항목 돈치안 가격 채널 ATR 변동성 채널
상단선 직전 20일 고가의 최댓값 20일 EMA + 2×ATR(14)
진입 신호 종가가 상단선 위로 처음 돌파 동일
체결 신호 다음 거래일 시가 동일
일반 청산 종가가 직전 10일 저가 아래로 이탈 동일
손실 청산 종가 기준 진입가 대비 -10% 이하 동일
시간 청산 최대 120거래일 동일
거래비용 매수·매도 각각 0.05% 가정 동일
보유 중 재진입 금지 동일

상단선 계산에는 당일 고가를 넣지 않습니다. 당일 고가까지 포함한 채널과 당일 종가를 비교하면 돌파 판단이 왜곡될 수 있기 때문에 shift(1)로 직전 거래일까지의 정보만 사용합니다.

신호가 확정된 종가에 곧바로 체결하지도 않습니다. 종가 신호와 다음 시가 체결을 분리한 실험과 같은 방식으로 주문을 하루 대기시킨 뒤 다음 거래일 시가에 실행합니다.

3. 30종목을 동일 비중으로 비교한다

한 종목의 좋은 돌파 사례만 고르면 채널 선택의 차이를 과장하기 쉽습니다. 이번에는 앞선 SYSTEM LAB과 같은 국내 주식 30종목을 사용했습니다.

  • 검증 구간: 2014년부터 2025년까지
  • 가격: yfinance 수정주가 일봉
  • 방향: 현물 매수만 허용하는 롱온리
  • 종목별 시작 자산: 1로 정규화
  • 포트폴리오: 유효한 종목별 자산곡선을 동일 비중 평균
  • 비교 지표: 누적수익률, MDD, Profit Factor, 승률, 거래 수, 평균 거래 수익률, 보유 기간과 노출 비중

상장 시점이 늦은 종목은 실제로 데이터가 존재하는 구간부터 참여합니다. 종목마다 별도 자금을 배정한 뒤 자산곡선을 평균했기 때문에 동시에 발생한 신호끼리 한 계좌의 현금을 두고 경쟁하지는 않습니다.

4. 전체 Python 코드

필요한 패키지는 터미널에서 먼저 설치합니다.

TEXT
python -m pip install yfinance pandas numpy matplotlib pyarrow

아래 코드 하나가 가격을 수집하고 두 채널을 계산한 뒤, 다음 시가 체결 백테스트와 결과 표·거래 내역·비교 차트를 저장합니다.

PYTHON
#!/usr/bin/env python3
"""돈치안 가격 채널과 ATR 변동성 채널 돌파 전략을 같은 조건에서 비교한다."""

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"
DONCHIAN_WINDOW = 20
ATR_WINDOW = 14
ATR_CENTER_WINDOW = 20
ATR_MULTIPLIER = 2.0
EXIT_WINDOW = 10
STOP_LOSS = 0.10
MAX_HOLDING_DAYS = 120
COST_RATE = 0.0005
MIN_ROWS = 504
CHART_FILE = Path("channel_breakout_chart.png")
RESULT_FILE = Path("channel_breakout_results.csv")
TRADE_FILE = Path("channel_breakout_trades.csv")
PRICE_CACHE_FILE = Path("channel_breakout_prices.parquet")

def download_prices() -> dict[str, pd.DataFrame]:
    if PRICE_CACHE_FILE.is_file():
        cached = pd.read_parquet(PRICE_CACHE_FILE)
        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")}

    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_START) & (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_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
    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()
    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_center"] = data["close"].ewm(span=ATR_CENTER_WINDOW, adjust=False, min_periods=ATR_CENTER_WINDOW).mean()
    data["atr_upper"] = data["atr_center"] + ATR_MULTIPLIER * data["atr"]
    data["donchian_upper"] = data["high"].shift(1).rolling(DONCHIAN_WINDOW, min_periods=DONCHIAN_WINDOW).max()
    data["common_exit"] = data["low"].shift(1).rolling(EXIT_WINDOW, min_periods=EXIT_WINDOW).min()
    data["donchian_entry"] = (data["close"] > data["donchian_upper"]) & (data["close"].shift(1) <= data["donchian_upper"].shift(1))
    data["atr_entry"] = (data["close"] > data["atr_upper"]) & (data["close"].shift(1) <= data["atr_upper"].shift(1))
    data["exit_signal"] = (data["close"] < data["common_exit"]) & (data["close"].shift(1) >= data["common_exit"].shift(1))
    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}
            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
                trades.append({**current_trade, "exit_signal_date": pending_order["signal_date"], "exit_date": date, "exit_price": exit_price, "trade_return": cash / current_trade["capital_before"] - 1.0, "holding_days": index - current_trade["entry_index"], "exit_reason": pending_order["reason"], "forced_exit": False})
                current_trade = None
            pending_order = None

        if shares > 0.0:
            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": "10% 손절"}
            elif bool(row["exit_signal"]):
                pending_order = {"side": "sell", "signal_date": date, "reason": "10일 저가 이탈"}
            elif holding_days >= MAX_HOLDING_DAYS:
                pending_order = {"side": "sell", "signal_date": date, "reason": "120일 청산"}
        elif bool(row[entry_column]):
            pending_order = {"side": "buy", "signal_date": date}

        daily_records.append({"date": date, "equity": cash + shares * float(row["close"]), "exposure": float(shares > 0.0)})

    if shares > 0.0:
        last_date = data.index[-1]
        last_close = float(data.iloc[-1]["close"])
        cash = shares * last_close * (1.0 - COST_RATE)
        trades.append({**current_trade, "exit_signal_date": last_date, "exit_date": last_date, "exit_price": last_close, "trade_return": cash / current_trade["capital_before"] - 1.0, "holding_days": len(data) - 1 - current_trade["entry_index"], "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), "평균거래수익률": float(trades["trade_return"].mean()), "중앙보유일": float(trades["holding_days"].median()), "노출비중": float(portfolio["exposure"].mean()), "손절수": int((trades["exit_reason"] == "10% 손절").sum())}

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 choose_example(prepared: dict[str, pd.DataFrame]) -> tuple[str, int]:
    candidates = []
    for ticker, data in prepared.items():
        yearly = data.assign(year=data.index.year).groupby("year")[["donchian_entry", "atr_entry"]].sum()
        yearly["difference"] = (yearly["donchian_entry"] - yearly["atr_entry"]).abs()
        for year, row in yearly.iterrows():
            if row["donchian_entry"] + row["atr_entry"] >= 3:
                candidates.append((float(row["difference"]), float(row["donchian_entry"] + row["atr_entry"]), ticker, int(year)))
    if not candidates:
        return TICKERS[0], 2020
    _, _, ticker, year = max(candidates)
    return ticker, year

def draw_chart(summary: pd.DataFrame, ticker_results: pd.DataFrame, prepared: dict[str, pd.DataFrame]) -> None:
    donchian_color = "#0F766E"
    atr_color = "#F59E0B"
    example_ticker, example_year = choose_example(prepared)
    example = prepared[example_ticker].loc[f"{example_year}-01-01":f"{example_year}-12-31"]

    fig = plt.figure(figsize=(13, 9), facecolor="white")
    grid = fig.add_gridspec(2, 2, height_ratios=[1.55, 1.0], hspace=0.34, wspace=0.27)
    channel_axis = fig.add_subplot(grid[0, :])
    scatter_axis = fig.add_subplot(grid[1, 0])
    metric_axis = fig.add_subplot(grid[1, 1])

    channel_axis.plot(example.index, example["close"], color="#14213D", linewidth=2.0, label="종가")
    channel_axis.plot(example.index, example["donchian_upper"], color=donchian_color, linewidth=1.8, label="직전 20일 고가")
    channel_axis.plot(example.index, example["atr_upper"], color=atr_color, linewidth=1.8, label="20일 EMA + 2×ATR")
    donchian_points = example.loc[example["donchian_entry"]]
    atr_points = example.loc[example["atr_entry"]]
    channel_axis.scatter(donchian_points.index, donchian_points["close"], marker="^", s=75, color=donchian_color, edgecolor="white", linewidth=0.8, zorder=4)
    channel_axis.scatter(atr_points.index, atr_points["close"], marker="o", s=60, color=atr_color, edgecolor="white", linewidth=0.8, zorder=4)
    channel_axis.set_title(f"같은 상승 돌파라도 채널이 움직이는 방식이 다르다 · {example_ticker} {example_year}", fontsize=17, fontweight="bold")
    channel_axis.set_ylabel("수정주가")
    channel_axis.legend(frameon=False, ncol=3, loc="upper left")

    scatter_axis.scatter(ticker_results["돈치안 가격 채널"] * 100.0, ticker_results["ATR 변동성 채널"] * 100.0, s=58, color="#334155", alpha=0.78)
    values = ticker_results.to_numpy() * 100.0
    low = min(float(np.nanmin(values)), 0.0) - 10.0
    high = max(float(np.nanmax(values)), 0.0) + 10.0
    scatter_axis.plot([low, high], [low, high], color="#94A3B8", linestyle="--", linewidth=1.2)
    scatter_axis.axhline(0.0, color="#CBD5E1", linewidth=0.8)
    scatter_axis.axvline(0.0, color="#CBD5E1", linewidth=0.8)
    scatter_axis.set_xlim(low, high)
    scatter_axis.set_ylim(low, high)
    scatter_axis.set_title("종목별 누적수익률", fontsize=14, fontweight="bold")
    scatter_axis.set_xlabel("돈치안 가격 채널(%)")
    scatter_axis.set_ylabel("ATR 변동성 채널(%)")

    metric_axis.axis("off")
    metric_axis.set_title("같은 청산 조건에서 달라진 결과", fontsize=14, fontweight="bold", pad=10)
    rows = [
        ("누적수익률", f"{summary.loc['돈치안 가격 채널', '누적수익률']:+.1%}", f"{summary.loc['ATR 변동성 채널', '누적수익률']:+.1%}"),
        ("MDD", f"{summary.loc['돈치안 가격 채널', 'MDD']:.1%}", f"{summary.loc['ATR 변동성 채널', 'MDD']:.1%}"),
        ("Profit Factor", f"{summary.loc['돈치안 가격 채널', 'Profit Factor']:.2f}", f"{summary.loc['ATR 변동성 채널', 'Profit Factor']:.2f}"),
        ("승률", f"{summary.loc['돈치안 가격 채널', '승률']:.1%}", f"{summary.loc['ATR 변동성 채널', '승률']:.1%}"),
        ("거래수", f"{summary.loc['돈치안 가격 채널', '거래수']:,.0f}", f"{summary.loc['ATR 변동성 채널', '거래수']:,.0f}"),
    ]
    table = metric_axis.table(cellText=rows, colLabels=["지표", "돈치안", "ATR"], cellLoc="center", colLoc="center", loc="center", bbox=[0.0, 0.03, 1.0, 0.88])
    table.auto_set_font_size(False)
    table.set_fontsize(11)
    for (row, column), cell in table.get_celld().items():
        cell.set_edgecolor("#E2E8F0")
        if row == 0:
            cell.set_facecolor("#E2E8F0")
            cell.set_text_props(fontweight="bold", color="#14213D")
        elif column == 1:
            cell.set_text_props(color=donchian_color, fontweight="bold")
        elif column == 2:
            cell.set_text_props(color=atr_color, fontweight="bold")

    fig.suptitle("가격의 극값과 변동성 기준은 서로 다른 돌파 신호를 만든다", fontsize=20, fontweight="bold", y=0.99)
    for axis in (channel_axis, scatter_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()
prices = download_prices()
prepared = {ticker: prepare_price(frame) for ticker, frame in prices.items()}
calendar = pd.DatetimeIndex(sorted(set().union(*(data.index for data in prepared.values()))))
strategies = {"돈치안 가격 채널": "donchian_entry", "ATR 변동성 채널": "atr_entry"}
trade_frames = {label: [] for label in strategies}
equity_series = {label: [] for label in strategies}
exposure_series = {label: [] for label in strategies}
ticker_returns = {label: {} for label in strategies}

for ticker, data in prepared.items():
    for label, entry_column in strategies.items():
        trades, daily = run_backtest(data, entry_column)
        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))
        ticker_returns[label][ticker] = float(daily["equity"].iloc[-1] - 1.0)

all_trades = {}
portfolios = {}
summary_rows = {}
for label in strategies:
    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)
ticker_results = pd.DataFrame(ticker_returns)
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, ticker_results, prepared)

donchian_better = int((ticker_results["돈치안 가격 채널"] > ticker_results["ATR 변동성 채널"]).sum())
both_profitable = int(((ticker_results["돈치안 가격 채널"] > 0.0) & (ticker_results["ATR 변동성 채널"] > 0.0)).sum())
print(summary.to_string(formatters={"누적수익률": "{:+.2%}".format, "MDD": "{:.2%}".format, "Profit Factor": "{:.2f}".format, "승률": "{:.2%}".format, "평균거래수익률": "{:+.2%}".format, "중앙보유일": "{:.0f}".format, "노출비중": "{:.2%}".format}))
print(f"돈치안 수익률 우위 종목: {donchian_better}/{len(ticker_results)}")
print(f"두 전략 모두 수익 종목: {both_profitable}/{len(ticker_results)}")
print(f"결과 저장: {RESULT_FILE}")
print(f"거래 저장: {TRADE_FILE}")
print(f"차트 저장: {CHART_FILE}")

donchian_entryatr_entry만 전략별로 다릅니다. run_backtest()의 청산, 체결, 비용과 최대 보유 기간은 두 전략이 같은 함수를 사용합니다.

yfinance의 과거 수정주가는 기업행사 반영에 따라 다시 조정될 수 있습니다. 코드는 최초 다운로드 결과를 Parquet로 저장해 같은 실험을 반복할 때 데이터 변경과 전략 변경이 섞이지 않도록 했습니다. 새 수정 내역으로 다시 계산하려면 생성된 20260904_system_trading_channel_breakout_prices.parquet 파일을 지우고 실행하면 됩니다.

5. 거래는 25.45% 줄었지만 수익률도 낮아졌다

작성 환경에서 실행한 결과입니다.

지표 돈치안 가격 채널 ATR 변동성 채널 변화
누적수익률 +182.05% +160.64% -21.41%p
MDD -17.78% -17.76% +0.02%p
Profit Factor 1.79 1.92 +0.13
승률 39.70% 40.55% +0.85%p
거래 수 1,277회 952회 -325회
평균 거래 수익률 +2.97% +3.55% +0.58%p
중앙 보유 기간 19거래일 19거래일 동일
노출 비중 35.85% 27.71% -8.14%p
10% 손실 청산 144회 122회 -22회
돈치안 가격 채널과 ATR 변동성 채널 돌파 전략 비교

ATR 채널은 돈치안 채널보다 거래를 25.45% 줄였습니다. 변동성이 커질수록 상단선이 멀어져 작은 돌파를 통과시키지 않았고, 그 결과 시장 노출 비중도 8.14%p 낮아졌습니다.

거래를 적게 했지만 거래 한 건의 평균 수익률은 2.97%에서 3.55%로 높아졌습니다. Profit Factor와 승률도 소폭 개선됐습니다. ATR 채널이 더 엄격한 확인선으로 작동했다는 뜻입니다.

그렇다고 전체 성과까지 모두 좋아진 것은 아닙니다. 누적수익률은 돈치안 채널이 21.41%p 높았고 MDD는 두 전략이 거의 같았습니다. 거래 감소가 그대로 위험 감소로 이어지지는 않았습니다.

6. 더 적게 거래한 ATR 채널의 수익률이 낮은 이유

ATR 채널은 변동성이 확대될 때 진입선도 위로 멀어집니다. 급등 초기에 가격이 오르는 동시에 ATR이 커지면 가격이 도망가는 만큼 통과 기준도 높아질 수 있습니다.

이 구조는 애매한 돌파를 줄이는 데 도움이 되지만 강한 추세의 초반도 놓칠 수 있습니다. 실제로 ATR 채널은 평균 거래 수익률이 높았음에도 전체 거래와 노출 시간이 줄어 최종 누적수익률이 낮았습니다.

반대로 돈치안 채널은 직전 20일 최고가만 넘으면 신호를 냅니다. 변동성이 커졌다는 이유로 돌파선을 추가로 멀리 보내지 않기 때문에 더 자주, 더 일찍 시장에 참여합니다.

종목별로 나누면 돈치안 채널의 누적수익률이 더 높았던 종목은 30개 중 16개였습니다. 거의 절반으로 갈렸다는 점은 전체 평균 하나만 보고 모든 종목에서 돈치안이 우수했다고 해석하면 안 된다는 뜻입니다.

7. Profit Factor가 높으면 ATR 채널이 더 좋은가

Profit Factor 1.92는 손실 거래의 손실 합계 1에 대해 수익 거래의 수익 합계가 1.92였다는 뜻입니다. 돈치안 채널의 1.79보다 거래 효율은 높았습니다.

그러나 Profit Factor는 시장에 얼마나 오래 노출됐는지 알려주지 않습니다. ATR 채널은 노출 비중이 27.71%로 돈치안보다 낮았습니다. 좋은 거래만 남기는 대신 참여 기회 자체를 줄였기 때문에 최종 자산은 더 작을 수 있습니다.

우선하는 목표 이번 표본에서 유리한 채널
높은 누적수익률 돈치안 가격 채널
적은 거래와 낮은 시장 노출 ATR 변동성 채널
높은 Profit Factor와 평균 거래 수익률 ATR 변동성 채널
낮은 MDD 두 전략이 사실상 비슷
더 빠른 돌파 참여 돈치안 가격 채널

누적수익률과 MDD·노출 비중을 함께 읽는 방법처럼 채널 선택도 하나의 지표로 결정할 수 없습니다. 전략을 실제 계좌에서 운영할 때 거래 빈도와 시장 노출을 감당할 수 있는지도 함께 봐야 합니다.

8. 이번 실험에서 놓치면 안 되는 한계

첫째, 돈치안 20일과 ATR 배수 2는 최적값이 아닙니다. 기간과 배수를 바꾸면 거래 수와 우위 전략이 달라질 수 있습니다.

둘째, 일봉 종가에서 손실을 확인하고 다음 시가에 청산합니다. 장중 -10% 가격에 즉시 체결되는 실제 스톱 주문을 재현한 결과가 아닙니다.

셋째, 현재 확인 가능한 대형·유동성 종목 30개를 사용했습니다. 상장폐지 종목이 빠진 생존 편향이 있고 상장 시점도 서로 다릅니다.

넷째, 수정주가와 매수·매도 각각 0.05%의 가정 비용을 사용했습니다. 실제 세금, 호가 스프레드, 시장 충격과 주문 실패는 계좌와 시점에 따라 달라집니다.

다섯째, 현물 매수 전략만 비교했습니다. 원래의 채널 돌파 전략처럼 하락 돌파에서 매도 포지션을 잡는 구조는 포함하지 않았습니다.

9. 정리

이번 채널 돌파 실험에서 확인한 사실은 세 가지입니다.

  1. ATR 변동성 채널은 돈치안 가격 채널보다 거래를 325회 줄이고 Profit Factor와 평균 거래 수익률을 높였습니다.
  2. 더 엄격한 신호는 노출 비중을 낮췄지만 강한 추세의 일부도 놓쳐 누적수익률은 21.41%p 낮았습니다.
  3. MDD는 거의 같았고 종목별 우위도 16대 14로 갈려, 한 채널이 모든 조건에서 우수하다고 볼 수 없었습니다.

채널 돌파 전략에서 상단선은 단순한 선 하나가 아닙니다. 어떤 움직임을 추세 시작으로 인정하고 어떤 움직임을 소음으로 버릴지 결정하는 문턱입니다.

다음 글에서는 진입 규칙을 하나로 고정한 뒤 고정 손절, 목표 수익, 보유 기간과 ATR 트레일링 청산을 비교합니다. 진입보다 청산이 전략의 수익 구조를 얼마나 크게 바꾸는지 확인하겠습니다.

이 글은 과거 데이터를 이용한 백테스트 예제이며 특정 종목이나 전략의 미래 수익을 보장하지 않습니다. 실제 투자에서는 세금, 수수료, 슬리피지, 체결 가능성과 계좌 위험을 별도로 검토해야 합니다.


본 포스팅의 백테스트 결과는 과거 데이터와 가정된 거래비용을 바탕으로 한 예시이며 미래 성과를 보장하지 않습니다. 특정 종목이나 매매전략의 추천이 아니며 투자 판단과 책임은 투자자 본인에게 있습니다.