같은 돌파 전략도 청산 규칙에 따라 결과가 달라지는 이유

매수 신호가 같으면 전략 결과도 비슷할 것이라고 생각하기 쉽습니다. 하지만 언제 매도할지를 바꾸면 거래 수, 승률, 손익비와 자산곡선이 전혀 다른 모습으로 바뀝니다.

이번에는 돈치안 20일 고가 돌파로 진입을 고정하고 청산만 네 가지로 나눴습니다. 국내 주식 30종목에서 누적수익률은 217.10%부터 556.86%까지 벌어졌고 MDD도 -19.90%부터 -29.79%까지 차이가 났습니다.

가장 높은 수익률을 기록한 고정 손절형은 수익 거래를 최대 120거래일까지 열어 둔 대신 가장 큰 낙폭을 감수했습니다. 수익률이 높았다는 이유만으로 편하게 운용할 수 있는 전략이었다고 볼 수는 없습니다.

SYSTEM LAB 10 핵심 결과

  • 고정 손절형: 누적수익률 +556.86%, MDD -29.79%
  • 40일 보유: 누적수익률 +369.45%, MDD -19.90%
  • +20% 목표: 승률 45.74%, Profit Factor 1.59
  • 3ATR 추적: 평균손실 -7.12%, 노출 비중 39.94%
  • 같은 진입 규칙에서도 수익률은 최대 약 2.6배 차이

1. 진입보다 청산이 더 어려운 이유

진입은 새로운 거래를 시작할지 결정합니다. 청산은 이미 발생한 손실을 어디까지 허용하고, 진행 중인 수익을 얼마나 오래 보유할지 결정합니다.

고정 손절은 허용 손실을 분명하게 만듭니다. 목표 수익은 일정 수익을 빠르게 확정하고, 시간 청산은 방향이 나오지 않는 거래에서 자금을 회수합니다. ATR 추적 청산은 변동성에 따라 가격을 따라가며 수익 보호선을 움직입니다.

각 규칙이 해결하는 문제가 다르기 때문에 가장 좋은 청산 하나가 존재한다고 가정하면 안 됩니다.

청산 규칙 우선 해결하려는 문제 포기할 수 있는 부분
고정 손절 1회 손실 한도 고정 수익 거래의 큰 되돌림
목표 수익 수익을 손실로 되돌리는 상황 목표가 이후의 큰 추세
보유 기간 자금이 오래 묶이는 상황 늦게 시작되는 추세
ATR 추적 종목별 변동성 차이 흔들림 뒤 다시 이어지는 추세

진입과 청산 규칙을 분리해야 하는 이유에서도 설명했듯이, 좋은 진입점만 찾는 것으로 전략은 완성되지 않습니다.

2. 돈치안 돌파 진입은 네 전략이 모두 같다

앞선 돈치안 가격 채널과 ATR 변동성 채널 비교에서 사용한 돈치안 돌파를 공통 진입 규칙으로 선택했습니다.

TEXT
진입 신호 = 종가가 계산일 이전 20거래일 고가의 최댓값을 상향 돌파
체결 가격 = 신호 다음 거래일 시가

보유 중에는 새로운 돌파 신호가 나와도 추가로 매수하지 않습니다. 진입 종목, 신호 계산, 다음 시가 체결과 매수·매도 각각 0.05%의 가정 비용까지 네 전략이 모두 같습니다.

달라지는 것은 포지션을 보유한 뒤의 청산 판단뿐입니다.

3. 네 가지 청산을 어떻게 비교했는가

손실을 무제한 방치하는 전략과 손절 전략을 비교하면 청산 방식보다 위험 한도의 유무가 결과를 지배합니다. 그래서 네 전략 모두 종가 기준 -10% 손실 청산을 공통 안전장치로 적용했습니다.

전략 이름 공통 손실 한도 추가 청산 최대 보유
고정 손절 -10% 없음 120거래일
+20% 목표 -10% 수익률 +20% 이상 120거래일
40일 보유 -10% 40거래일 경과 40거래일
3ATR 추적 -10% 최고 종가에서 3×ATR 하락 120거래일

여기서 고정 손절형은 수익 거래를 무기한 보유하는 전략이 아닙니다. -10% 손실에 도달하지 않은 거래는 120거래일에 청산합니다. 다른 전략도 별도 조건이 발생하지 않으면 같은 120일 상한을 적용합니다.

손실·목표·ATR 선에 닿는 순간의 장중 체결을 가정하지 않았습니다. 일봉 종가에서 조건을 확인하고 다음 거래일 시가에 주문을 실행합니다. 이는 종가 신호와 다음 시가 체결을 분리한 실험과 같은 보수적인 처리 방식입니다.

4. 전체 Python 코드

필요한 패키지를 먼저 설치합니다.

TEXT
python -m pip install yfinance pandas numpy matplotlib pyarrow

아래 코드는 가격 수집부터 돈치안 진입, 네 가지 청산 백테스트, 성과표와 차트 저장까지 한 번에 실행합니다.

PYTHON
#!/usr/bin/env python3
"""같은 돈치안 돌파 진입에 네 가지 청산 규칙을 적용해 성과를 비교한다."""

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"
ENTRY_WINDOW = 20
ATR_WINDOW = 14
STOP_LOSS = 0.10
PROFIT_TARGET = 0.20
TIME_EXIT_DAYS = 40
ATR_MULTIPLIER = 3.0
MAX_HOLDING_DAYS = 120
COST_RATE = 0.0005
MIN_ROWS = 504
PRICE_CACHE_FILE = Path("exit_rules_prices.parquet")
CHART_FILE = Path("exit_rules_chart.png")
RESULT_FILE = Path("exit_rules_results.csv")
TRADE_FILE = Path("exit_rules_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_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["entry_channel"] = data["high"].shift(1).rolling(ENTRY_WINDOW, min_periods=ENTRY_WINDOW).max()
    data["entry_signal"] = (data["close"] > data["entry_channel"]) & (data["close"].shift(1) <= data["entry_channel"].shift(1))
    return data.loc[(data.index >= TEST_START) & (data.index < TEST_END)].copy()

def run_backtest(data: pd.DataFrame, exit_rule: 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
                initial_atr = float(row["atr"])
                current_trade = {"entry_signal_date": pending_order["signal_date"], "entry_date": date, "entry_index": index, "entry_price": entry_price, "capital_before": capital_before, "highest_close": float(row["close"]), "trailing_stop": entry_price - ATR_MULTIPLIER * initial_atr}
            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:
            current_trade["highest_close"] = max(current_trade["highest_close"], float(row["close"]))
            if pd.notna(row["atr"]):
                candidate_stop = current_trade["highest_close"] - ATR_MULTIPLIER * float(row["atr"])
                current_trade["trailing_stop"] = max(current_trade["trailing_stop"], candidate_stop)
            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 exit_rule == "profit_target" and close_return >= PROFIT_TARGET:
                pending_order = {"side": "sell", "signal_date": date, "reason": "20% 목표"}
            elif exit_rule == "time_exit" and holding_days >= TIME_EXIT_DAYS:
                pending_order = {"side": "sell", "signal_date": date, "reason": "40일 보유"}
            elif exit_rule == "atr_trailing" and float(row["close"]) <= current_trade["trailing_stop"]:
                pending_order = {"side": "sell", "signal_date": date, "reason": "3ATR 추적"}
            elif holding_days >= MAX_HOLDING_DAYS:
                pending_order = {"side": "sell", "signal_date": date, "reason": "120일 상한"}
        elif bool(row["entry_signal"]):
            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(winners.mean()), "평균손실": float(losers.mean()), "평균손익비": float(winners.mean() / losers.abs().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 draw_chart(summary: pd.DataFrame, portfolios: dict[str, pd.DataFrame], all_trades: dict[str, pd.DataFrame]) -> None:
    colors = {"고정 손절": "#64748B", "+20% 목표": "#F59E0B", "40일 보유": "#7C3AED", "3ATR 추적": "#0F766E"}
    fig = plt.figure(figsize=(13, 9), facecolor="white")
    grid = fig.add_gridspec(2, 2, height_ratios=[1.65, 1.0], hspace=0.35, wspace=0.28)
    equity_axis = fig.add_subplot(grid[0, :])
    risk_axis = fig.add_subplot(grid[1, 0])
    distribution_axis = fig.add_subplot(grid[1, 1])

    for label, portfolio in portfolios.items():
        equity_axis.plot(portfolio.index, portfolio["equity"], color=colors[label], linewidth=2.1, label=f"{label} {summary.loc[label, '누적수익률']:+.1%}")
    equity_axis.axhline(1.0, color="#CBD5E1", linewidth=1.0)
    equity_axis.set_title("같은 돌파 진입도 청산 규칙에 따라 다른 자산 경로를 만든다", fontsize=18, fontweight="bold", pad=16)
    equity_axis.set_ylabel("30종목 동일 비중 누적 자산")
    equity_axis.legend(frameon=False, ncol=2, loc="upper left")

    for label in summary.index:
        risk_axis.scatter(abs(summary.loc[label, "MDD"]) * 100.0, summary.loc[label, "누적수익률"] * 100.0, s=70 + summary.loc[label, "거래수"] / 8.0, color=colors[label], edgecolor="white", linewidth=1.2, label=label)
        risk_axis.annotate(label, (abs(summary.loc[label, "MDD"]) * 100.0, summary.loc[label, "누적수익률"] * 100.0), xytext=(5, 5), textcoords="offset points", fontsize=9)
    risk_axis.set_title("낙폭과 누적수익률", fontsize=14, fontweight="bold")
    risk_axis.set_xlabel("MDD 절댓값(%)")
    risk_axis.set_ylabel("누적수익률(%)")

    labels = list(all_trades)
    distributions = [all_trades[label]["trade_return"].clip(-0.2, 0.4) * 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="#94A3B8", linewidth=1.0)
    distribution_axis.set_title("거래 수익률 분포", fontsize=14, fontweight="bold")
    distribution_axis.set_ylabel("거래 수익률(%, -20~+40 표시)")
    distribution_axis.tick_params(axis="x", rotation=12)

    fig.suptitle("청산은 수익을 끝내는 규칙이 아니라 전략의 손익 구조를 정하는 규칙이다", fontsize=20, fontweight="bold", y=0.99)
    for axis in (equity_axis, risk_axis, distribution_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 = {"고정 손절": "fixed_stop", "+20% 목표": "profit_target", "40일 보유": "time_exit", "3ATR 추적": "atr_trailing"}
trade_frames = {label: [] for label in strategies}
equity_series = {label: [] for label in strategies}
exposure_series = {label: [] for label in strategies}

for ticker, data in prepared.items():
    for label, exit_rule in strategies.items():
        trades, daily = run_backtest(data, exit_rule)
        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))

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)
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, portfolios, all_trades)

print(summary.to_string(formatters={"누적수익률": "{:+.2%}".format, "MDD": "{:.2%}".format, "Profit Factor": "{:.2f}".format, "승률": "{:.2%}".format, "평균이익": "{:+.2%}".format, "평균손실": "{:.2%}".format, "평균손익비": "{:.2f}".format, "중앙보유일": "{:.0f}".format, "노출비중": "{:.2%}".format}))
for label, trades in all_trades.items():
    print(f"{label} 청산 사유: {trades['exit_reason'].value_counts().to_dict()}")
print(f"결과 저장: {RESULT_FILE}")
print(f"거래 저장: {TRADE_FILE}")
print(f"차트 저장: {CHART_FILE}")

run_backtest()는 네 전략이 공유하는 하나의 체결 엔진입니다. exit_rule만 바꿔 목표 수익, 시간 또는 ATR 추적 조건을 추가합니다.

ATR 추적선은 보유 중 기록한 최고 종가에서 3×ATR(14)를 뺀 값입니다. 변동성이 줄어 후보 청산선이 높아지면 기존 선을 올리지만, 변동성이 다시 커졌다는 이유로 청산선을 아래로 내리지는 않습니다.

5. 같은 진입인데 결과는 네 갈래로 나뉘었다

검증 구간과 종목군은 앞선 채널 돌파 실험과 같습니다. 종목별 자산을 1로 시작해 동일 비중으로 평균했습니다.

지표 고정 손절 +20% 목표 40일 보유 3ATR 추적
누적수익률 +556.86% +217.10% +369.45% +218.56%
MDD -29.79% -23.73% -19.90% -20.06%
Profit Factor 2.41 1.59 1.78 1.85
승률 40.90% 45.74% 48.85% 40.44%
거래 수 736회 1,091회 1,216회 1,145회
평균 이익 +39.00% +21.40% +17.53% +19.39%
평균 손실 -11.18% -11.35% -9.39% -7.12%
평균 손익비 3.49 1.89 1.87 2.72
중앙 보유 기간 85일 29일 41일 24일
노출 비중 62.14% 51.11% 45.02% 39.94%
같은 돈치안 돌파 진입에 네 가지 청산 규칙을 적용한 결과

승률이 가장 높은 전략은 40일 보유였지만 누적수익률이 가장 높은 전략은 고정 손절형이었습니다. 승률과 누적수익률이 같은 방향으로 움직이지 않은 이유는 평균 이익의 크기에서 찾을 수 있습니다.

고정 손절형의 평균 이익은 39.00%, 평균 손실은 -11.18%였습니다. 승률은 40.90%에 불과했지만 평균 손익비가 3.49에 달해 적은 수익 거래가 여러 손실을 보완했습니다.

6. 고정 손절형의 557%에는 큰 낙폭이 붙어 있다

고정 손절형은 별도의 수익 청산을 두지 않아 강한 추세를 가장 오래 보유했습니다. 736건 가운데 -10% 손실 조건으로 끝난 거래는 398건, 120일 상한까지 보유한 거래는 319건이었습니다.

수익을 빨리 끊지 않은 결과 평균 이익은 네 전략 중 가장 컸고 누적수익률도 556.86%로 높았습니다. 반면 중앙 보유 기간은 85일, 노출 비중은 62.14%로 가장 높았습니다.

MDD도 -29.79%로 가장 깊었습니다. 40일 보유 전략보다 누적수익률은 187.41%p 높았지만 낙폭은 9.89%p 더 컸습니다.

이 결과는 손절만 있으면 수익률이 높다는 뜻이 아닙니다. 고정한 종목군과 기간에서 120일 동안 살아남은 큰 추세가 전체 결과를 끌어올렸다는 뜻입니다. 다른 구간에서 장기 추세가 적다면 높은 노출과 깊은 낙폭만 남을 수 있습니다.

7. +20% 목표 수익은 승률을 높였지만 손익비를 낮췄다

목표 수익 전략은 425건을 +20% 조건으로 청산했습니다. 고정 손절형보다 거래가 빨리 끝나 재진입 기회가 늘었고 승률도 45.74%로 높아졌습니다.

하지만 평균 이익은 21.40%로 줄었고 Profit Factor는 네 전략 중 가장 낮은 1.59였습니다. 수익은 +20% 부근에서 잘랐지만 종가 확인과 다음 시가 체결 때문에 평균 손실은 -11.35%로 고정 손절형과 비슷했습니다.

목표 수익은 이기는 거래의 수를 늘려줄 수 있지만 큰 추세의 오른쪽 꼬리를 잘라냅니다. 승률이 높아졌다는 이유만으로 전략의 전체 효율이 좋아졌다고 판단하기 어려운 사례입니다.

8. 40일 보유와 3ATR 추적은 무엇을 줄였는가

40일 보유 전략은 누적수익률 369.45%, MDD -19.90%를 기록했습니다. 고정 손절형보다 최종 수익은 낮았지만 낙폭을 약 9.89%p 줄였습니다.

승률은 48.85%로 가장 높았고 거래 수도 1,216회로 가장 많았습니다. 정해진 시간이 지나면 결과와 관계없이 자금을 회수하기 때문에 새로운 돌파 신호에 참여할 기회가 늘었습니다.

3ATR 추적은 평균 손실을 -7.12%까지 줄였습니다. 공통 -10% 손실 조건보다 ATR 추적선이 먼저 작동한 거래가 많았기 때문입니다. 손실 청산 수도 174회로 다른 전략보다 적었습니다.

노출 비중은 39.94%, 중앙 보유 기간은 24일로 가장 낮았습니다. 다만 가격 흔들림에 따라 포지션이 빨리 끝나 누적수익률은 218.56%에 머물렀습니다. 낮은 노출과 작은 평균 손실을 얻는 대신 장기 추세 참여를 줄인 결과입니다.

9. 청산 규칙은 목표에 맞춰 선택해야 한다

운용 목표 이번 표본에서 가까운 규칙
큰 추세를 오래 보유 고정 손절+120일 상한
높은 승률과 일정한 자금 회전 40일 보유
평균 손실과 시장 노출 축소 3ATR 추적
일정 수익을 빠르게 확정 +20% 목표
높은 최종 누적수익률 고정 손절형
낮은 MDD 40일 보유와 3ATR 추적

누적수익률만 보면 고정 손절형을 선택하기 쉽습니다. 그러나 실제 계좌가 -29.79%의 낙폭과 62.14%의 장기 노출을 견딜 수 있는지는 별개의 문제입니다.

누적수익률과 MDD·노출 비중을 함께 읽는 방법을 적용하면 이번 결과는 하나의 승자를 고르는 표가 아니라 서로 다른 운용 성격을 비교하는 표에 가깝습니다.

10. 이번 실험의 한계

첫째, -10%, +20%, 40일과 3ATR은 최적값이 아닙니다. 인접한 값에서도 결과가 유지되는지 확인하지 않았습니다.

둘째, 손실과 목표 조건은 종가로 확인해 다음 시가에 체결했습니다. 장중 지정가·스톱 주문의 정확한 체결을 재현하지 않습니다.

셋째, 고정 손절형에도 120거래일 상한이 있습니다. 무기한 보유 전략과 같은 의미가 아닙니다.

넷째, 현재 확인 가능한 대형·유동성 종목 30개를 사용해 상장폐지 종목이 빠진 생존 편향이 남아 있습니다.

다섯째, yfinance 수정주가와 가정 비용을 사용했습니다. 실제 세금, 호가 스프레드, 시장 충격과 주문 실패를 모두 반영한 결과는 아닙니다.

11. 정리

이번 청산 실험에서 확인한 사실은 세 가지입니다.

  1. 같은 돈치안 돌파 진입에서도 청산 규칙만 바꾸자 누적수익률은 217.10%부터 556.86%까지 벌어졌습니다.
  2. 고정 손절형은 큰 수익 거래를 오래 보유해 가장 높은 수익률을 기록했지만 MDD와 시장 노출도 가장 높았습니다.
  3. 40일·ATR 추적 청산은 최종 수익 일부를 포기하는 대신 낙폭, 평균 손실 또는 노출 시간을 줄였습니다.

청산은 수익 거래를 끝내는 부수적인 규칙이 아닙니다. 전략이 작은 이익을 자주 쌓을지, 여러 손실을 감수하고 큰 추세를 기다릴지 결정하는 핵심 구조입니다.

다음 글에서는 가장 높은 과거 수익률 하나를 고르는 대신, 인접한 변수에서도 성과가 유지되는 평탄한 구간을 찾습니다. 청산 값이 조금만 달라져도 결과가 무너지는 과최적화 문제를 확인하겠습니다.

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


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