손절 없는 전략은 승률이 높은데 왜 위험할까? 초기·추적·시간 손절 비교

손절한 종목이 며칠 뒤 다시 오르면 손절하지 않는 편이 나았다고 생각하기 쉽습니다. 실제 백테스트에서도 손절을 없앤 전략이 더 높은 누적수익률과 승률을 기록할 때가 있습니다. 손실을 확정하지 않고 기다리면 반등한 거래가 수익으로 돌아오기 때문입니다.

하지만 회복한 거래만 남는 것은 아닙니다. 반등하지 못한 종목도 정해진 보유 기간이 끝날 때까지 계좌의 낙폭을 키웁니다. 수익률 한 칸만 보면 큰 회복 거래는 잘 보이고, 그 회복을 기다리는 동안 감수한 손실은 작게 보일 수 있습니다.

이번에는 같은 가격 돌파 신호를 사용하고 청산 규칙만 바꿨습니다. 손실 제한 없이 60거래일 보유한 결과와 초기 10% 손절, 고점 대비 10% 추적 손절, 20거래일 무수익 청산을 비교했습니다.

이 글의 핵심 결론

  • 손실 제한 없음은 승률 54.9%로 가장 높았지만 MDD -27.0%와 최악 거래 -56.4%도 가장 컸습니다.
  • 초기 10% 손절은 승률이 45.0%로 낮아졌지만 누적수익률은 +265.3%로 높아지고 MDD는 -16.6%로 줄었습니다.
  • 추적 10% 손절은 MDD가 -11.4%로 가장 작았지만 누적수익률도 +161.9%로 가장 낮았습니다.
  • 10% 손절도 손실을 정확히 -10%에서 막지는 못했습니다. 종가 확인 뒤 다음 시가에 청산하자 갭을 포함한 최악 거래는 초기 손절 -33.1%, 추적 손절 -31.8%였습니다.

손절 효과만 남기도록 진입은 고정했습니다

진입 신호까지 달라지면 손절 효과와 종목 선택 효과를 구분하기 어렵습니다. 네 규칙 모두 다음 조건을 그대로 사용했습니다.

구분 공통 조건
가격 돌파 종가가 직전 20거래일 고가를 처음 상향 돌파합니다.
상승 추세 종가가 120일 SMA 위에 있고 120일 SMA가 20거래일 전보다 높습니다.
진입 체결 종가에 신호를 확인한 다음 거래일 시가에 매수합니다.
최대 보유 다른 청산이 없더라도 60거래일이 지나면 다음 시가에 매도합니다.
거래비용 매수와 매도에 각각 0.15%를 적용합니다.

여기서 손실 제한 없음은 영원히 보유한다는 뜻이 아닙니다. 진입 이후 손실률을 이유로 조기 청산하지 않고, 공통 최대 보유 기간인 60거래일까지 기다리는 규칙입니다.

청산 규칙 조기 청산 조건 바꾸려는 위험
손실 제한 없음 없음 반등 기회를 끝까지 남깁니다.
초기 10% 손절 종가가 진입가보다 10% 이상 낮음 한 거래의 초기 실패를 제한합니다.
추적 10% 손절 종가가 보유 중 최고 종가보다 10% 이상 낮음 확보한 평가이익의 반납을 줄입니다.
20일 무수익 청산 20거래일이 지났는데 종가 수익률이 0% 이하 움직이지 않는 포지션의 자본 점유를 줄입니다.

같은 신호에 청산만 바꾸는 코드

아래 코드는 yfinance에서 받은 분할 반영 OHLC로 네 가지 청산 규칙을 계산합니다. 배당 재투자 조정은 사용하지 않았습니다. 신호를 종가에 확인한 뒤 다음 시가에 체결하고, 거래비용과 자산 곡선, MDD, 최장 회복 기간까지 포함합니다. 실행하면 네 규칙의 결과를 같은 성과표로 출력합니다.

TEXT
python -m pip install pandas yfinance
PYTHON
"""yfinance 배당 조정 제외 OHLC로 초기·추적·시간 손절을 비교하는 본문 예제."""

import pandas as pd
import yfinance as yf


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-08-29"
BREAKOUT_WINDOW = 20
TREND_WINDOW = 120
TREND_SLOPE_WINDOW = 20
MAXIMUM_HOLDING_DAYS = 60
INITIAL_STOP_RATE = 0.10
TRAILING_STOP_RATE = 0.10
TIME_STOP_DAYS = 20
ONE_WAY_COST_RATE = 0.0015


def add_entry_signal(price_data: pd.DataFrame) -> pd.DataFrame:
    signal_data = price_data.copy()
    signal_data["entry_channel"] = signal_data["high"].shift(1).rolling(BREAKOUT_WINDOW).max()
    signal_data["sma120"] = signal_data["close"].rolling(TREND_WINDOW).mean()
    price_breakout = (signal_data["close"] > signal_data["entry_channel"]) & (signal_data["close"].shift(1) <= signal_data["entry_channel"].shift(1))
    rising_trend = (signal_data["close"] > signal_data["sma120"]) & (signal_data["sma120"] > signal_data["sma120"].shift(TREND_SLOPE_WINDOW))
    signal_data["entry_signal"] = price_breakout & rising_trend
    return signal_data.loc[TEST_START:TEST_END].dropna(subset=["entry_channel", "sma120"])


def run_backtest(signal_data: pd.DataFrame, stop_rule: str) -> tuple[pd.Series, list[dict[str, object]]]:
    cash, position_shares = 1.0, 0.0
    active_trade: dict[str, object] | None = None
    pending_order: dict[str, object] | None = None
    equity_values: list[float] = []
    trades: list[dict[str, object]] = []
    for row_index, (trade_day, price_row) in enumerate(signal_data.iterrows()):
        if pending_order is not None:
            if pending_order["side"] == "buy" and position_shares == 0.0:
                entry_price = float(price_row["open"])
                capital_before_entry = cash
                position_shares = cash * (1.0 - ONE_WAY_COST_RATE) / entry_price
                cash = 0.0
                active_trade = {"signal_date": pending_order["signal_date"], "entry_date": trade_day, "entry_index": row_index, "entry_price": entry_price, "capital": capital_before_entry, "highest_close": float(price_row["close"])}
            elif pending_order["side"] == "sell" and position_shares > 0.0 and active_trade is not None:
                exit_price = float(price_row["open"])
                cash = position_shares * exit_price * (1.0 - ONE_WAY_COST_RATE)
                position_shares = 0.0
                trades.append({**active_trade, "exit_date": trade_day, "exit_price": exit_price, "trade_return": cash / float(active_trade["capital"]) - 1.0, "holding_days": row_index - int(active_trade["entry_index"]), "exit_reason": pending_order["reason"]})
                active_trade = None
            pending_order = None
        if position_shares > 0.0 and active_trade is not None:
            active_trade["highest_close"] = max(float(active_trade["highest_close"]), float(price_row["close"]))
            close_return = float(price_row["close"]) / float(active_trade["entry_price"]) - 1.0
            trailing_return = float(price_row["close"]) / float(active_trade["highest_close"]) - 1.0
            holding_days = row_index - int(active_trade["entry_index"])
            exit_reason = ""
            if stop_rule == "initial" and close_return <= -INITIAL_STOP_RATE:
                exit_reason = "진입가 대비 10% 손절"
            elif stop_rule == "trailing" and trailing_return <= -TRAILING_STOP_RATE:
                exit_reason = "고점 대비 10% 추적 손절"
            elif stop_rule == "time" and holding_days >= TIME_STOP_DAYS and close_return <= 0.0:
                exit_reason = "20거래일 무수익 청산"
            elif holding_days >= MAXIMUM_HOLDING_DAYS:
                exit_reason = "60거래일 경과"
            if exit_reason:
                pending_order = {"side": "sell", "reason": exit_reason}
        elif bool(price_row["entry_signal"]):
            pending_order = {"side": "buy", "signal_date": trade_day}
        equity_values.append(cash + position_shares * float(price_row["close"]))
    if position_shares > 0.0 and active_trade is not None:
        exit_price = float(signal_data.iloc[-1]["close"])
        cash = position_shares * exit_price * (1.0 - ONE_WAY_COST_RATE)
        trades.append({**active_trade, "exit_date": signal_data.index[-1], "exit_price": exit_price, "trade_return": cash / float(active_trade["capital"]) - 1.0, "holding_days": len(signal_data) - 1 - int(active_trade["entry_index"]), "exit_reason": "검증 기간 종료"})
        equity_values[-1] = cash
    return pd.Series(equity_values, index=signal_data.index), trades


def longest_recovery_days(equity: pd.Series) -> int:
    underwater = equity < equity.cummax()
    longest_days, current_start = 0, None
    for trade_day, is_underwater in underwater.items():
        if is_underwater and current_start is None:
            current_start = trade_day
        elif not is_underwater and current_start is not None:
            longest_days = max(longest_days, (trade_day - current_start).days)
            current_start = None
    if current_start is not None:
        longest_days = max(longest_days, (equity.index[-1] - current_start).days)
    return longest_days


def summarize_strategy(equity_curves: list[pd.Series], trades: list[dict[str, object]]) -> dict[str, float | int]:
    portfolio_equity = pd.concat(equity_curves, axis=1).ffill().fillna(1.0).mean(axis=1)
    drawdown = portfolio_equity / portfolio_equity.cummax() - 1.0
    trade_returns = pd.Series([float(trade["trade_return"]) for trade in trades])
    return {"누적수익률": float(portfolio_equity.iloc[-1] - 1.0), "MDD": float(drawdown.min()), "최장 회복 기간": longest_recovery_days(portfolio_equity), "거래 수": len(trades), "승률": float((trade_returns > 0.0).mean()), "최악 거래": float(trade_returns.min())}


def download_price_data(ticker: str) -> pd.DataFrame:
    raw_prices = yf.download(ticker, start=DOWNLOAD_START, end=TEST_END, auto_adjust=False, progress=False, multi_level_index=False, threads=False)
    raw_prices = raw_prices.rename(columns=str.lower).dropna(subset=["open", "high", "low", "close"]).copy()
    prices = raw_prices[["open", "high", "low", "close", "volume"]].copy()
    if len(prices) < 500:
        raise RuntimeError(f"검증 데이터가 부족합니다: {ticker}")
    return prices


def main() -> None:
    stop_rules = {"손실 제한 없음": "none", "초기 10% 손절": "initial", "추적 10% 손절": "trailing", "20일 무수익 청산": "time"}
    equity_by_rule = {name: [] for name in stop_rules}
    trades_by_rule = {name: [] for name in stop_rules}
    for ticker in TICKERS:
        signal_data = add_entry_signal(download_price_data(ticker))
        for rule_name, stop_rule in stop_rules.items():
            equity, trades = run_backtest(signal_data, stop_rule)
            equity_by_rule[rule_name].append(equity)
            trades_by_rule[rule_name].extend(trades)
    summary = pd.DataFrame([{"청산 규칙": name, **summarize_strategy(equity_by_rule[name], trades_by_rule[name])} for name in stop_rules])
    print(summary.to_string(index=False, formatters={"누적수익률": "{:+.1%}".format, "MDD": "{:.1%}".format, "승률": "{:.1%}".format, "최악 거래": "{:.1%}".format}))


if __name__ == "__main__":
    main()

highest_close는 진입 이후에 확인한 종가만 사용합니다. 당일 고가로 추적 손절선을 올린 뒤 같은 날 종가로 청산하는 방식과는 체결 가정이 다릅니다. 이 예제에서는 모든 청산 신호를 종가에 확정하고 다음 거래일 시가에 실행했습니다.

승률이 가장 높은 규칙의 낙폭도 가장 컸습니다

2014년 1월 2일부터 2026년 8월 28일까지 30종목을 종목별로 계산한 뒤 같은 비중으로 합쳤습니다.

청산 규칙 누적수익률 MDD 최장 회복 기간 거래 수 승률 최악 거래
손실 제한 없음 +241.9% -27.0% 1,010일 572회 54.9% -56.4%
초기 10% 손절 +265.3% -16.6% 1,017일 638회 45.0% -33.1%
추적 10% 손절 +161.9% -11.4% 1,065일 731회 44.5% -31.8%
20일 무수익 청산 +290.5% -17.0% 1,041일 698회 36.7% -42.2%
손실 제한 없음과 초기·추적·시간 손절의 자산 곡선 및 낙폭 비교

손실 제한 없음은 승률이 가장 높았습니다. 손실 중인 거래를 60거래일까지 보유했기 때문에 중간 하락을 회복한 거래가 승리로 바뀌었습니다. 그러나 누적수익률은 초기 손절과 시간 청산보다 낮았고, 회복하지 못한 최악 거래는 -56.4%까지 커졌습니다.

초기 10% 손절은 승률을 9.9%포인트 낮췄지만 누적수익률은 23.4%포인트 높이고 MDD는 10.4%포인트 줄였습니다. 일찍 끝난 포지션의 자본으로 다음 신호에 재진입한 결과까지 포함돼 있습니다. 이 표본에서 유리했다는 뜻이지 초기 10%가 모든 전략의 정답이라는 뜻은 아닙니다.

20일 무수익 청산은 승률이 36.7%로 가장 낮았는데도 누적수익률은 +290.5%로 가장 높았습니다. 손실 포지션은 일찍 비우면서도 20일 안에 수익으로 전환한 큰 상승 거래는 60일까지 보유했기 때문입니다. 승률만 높이면 성과가 좋아질 것이라는 생각과 반대되는 결과입니다.

추적 손절은 낙폭을 가장 많이 줄였지만 상승 도중 10% 조정을 받은 거래도 일찍 끝내 누적수익률이 가장 낮았습니다. 평가이익 보호와 장기 추세 보유가 서로 충돌한 결과입니다.

청산 규칙별 누적수익률, MDD, 최장 회복 기간과 최악 거래 비교

MDD가 작아졌다고 회복 기간까지 짧아지지는 않았습니다. 초기 손절의 최장 회복 기간은 1,017일, 추적 손절은 1,065일로 손실 제한 없음의 1,010일보다 길었습니다. 손절은 낙폭의 깊이를 바꿨지만 손실이 발생한 시점과 이후 수익의 순서까지 유리하게 만들지는 못했습니다.

10%가 특별히 유리한 값인지 확인하기 위해 손절 거리를 8%, 12%, 15%로도 바꿨습니다. 초기 손절의 누적수익률은 8~15% 범위에서 +244.5%~+277.2%, MDD는 -16.2%~-17.9%였습니다. 손실 제한 없음의 MDD -27.0%보다 모든 거리에서 낙폭이 작았습니다. 추적 손절도 같은 범위에서 MDD -11.0%~-15.7%로 낙폭 축소가 유지됐지만, 수익률과 MDD가 손절 거리에 따라 일정한 순서로 움직이지는 않았습니다. 한 값의 결과만 보고 최적 손절선을 단정할 수 없는 이유입니다.

거래 수도 같지 않습니다. 먼저 청산한 규칙은 다음 돌파 신호에 다시 진입할 수 있어 거래가 늘었습니다. 청산 규칙을 비교할 때는 거래당 손익뿐 아니라 시장 노출, 재진입 횟수와 추가 비용까지 함께 봐야 합니다.

손절이 불리했던 한미반도체 구간

2024년 2월 8일 한미반도체에서 돌파 신호가 발생했고 다음 거래일 시가 82,200원에 진입했습니다. 초기 손절 규칙은 종가 하락을 확인한 뒤 2월 22일 시가 75,700원에 청산해 비용 차감 후 -8.2%를 기록했습니다.

한미반도체에서 초기 손절 뒤 가격이 회복한 사례

차트의 초록 삼각형은 진입, 빨간 역삼각형은 초기 손절 청산, 파란 마름모는 손실 제한 없이 60거래일을 보유한 청산입니다. 같은 거래를 5월 14일까지 보유했다면 시가 140,100원에 청산해 +69.9%였습니다.

이 사례만 보면 손절은 큰 상승을 놓친 잘못된 선택처럼 보입니다. 그러나 손절 당시에는 이후 회복을 알 수 없습니다. 손절 규칙을 평가할 때는 살아 돌아온 종목만 골라 보는 사후확증을 피해야 합니다.

손절이 유리했던 삼성전자 구간

2024년 7월 4일 삼성전자에서 같은 돌파 신호가 발생했고 다음 시가 85,600원에 진입했습니다. 초기 10% 손절은 8월 6일 시가 74,900원에 체결돼 거래수익률 -12.8%를 기록했습니다.

삼성전자에서 초기 손절이 이후 추가 하락을 줄인 사례

손실 제한 없이 60거래일을 보유한 규칙은 10월 8일 시가 60,000원에 청산해 -30.1%였습니다. 한미반도체와 달리 기다림이 회복으로 이어지지 않았고, 초기 손절이 추가 하락과 자본 점유를 줄였습니다.

두 사례의 진입 규칙과 손절선은 같습니다. 차이는 손절 이후 가격 경로입니다. 어느 경로가 다음에 나올지 미리 구분할 수 없다면, 손절은 개별 거래의 정답이 아니라 여러 거래에서 감당할 손실 범위를 정하는 정책이어야 합니다.

10% 손절이 -10% 손실을 보장하지 않습니다

이번 초기 손절의 최악 거래는 -33.1%였습니다. 코드가 잘못된 것이 아니라 신호와 체결 시점을 분리했기 때문입니다.

TEXT
오늘 종가가 손절선 아래인지 확인
→ 다음 거래일 시가에 매도
→ 밤사이 악재나 하한가·갭 하락이 있으면 -10%보다 낮은 가격에 체결

장중 스톱 주문을 사용해도 지정한 가격의 체결을 보장하지는 않습니다. 유동성이 부족하거나 가격이 손절선을 건너뛰면 슬리피지가 발생합니다. 일봉 백테스트에서 종가가 손절선에 닿는 순간 정확히 그 가격으로 매도하면 실제보다 손실을 작게 계산할 수 있습니다.

ATR 계산법: 2배 손절선은 왜 완벽한 기준이 아닐까?에서 고정 비율 대신 변동성으로 손절 거리를 조정하는 방법을 확인할 수 있습니다. 샹들리에 청산 계산법은 고점과 ATR을 함께 사용하는 추적 청산 규칙입니다.

손절 거리보다 먼저 계좌 위험을 정해야 합니다

같은 10% 손절도 포지션 크기가 다르면 계좌에 미치는 영향이 다릅니다. 손절선이 멀수록 수량을 줄여야 같은 위험 예산을 유지할 수 있습니다.

TEXT
허용 손실 금액 = 계좌 평가액 × 거래당 위험 비율
매수 수량 = 허용 손실 금액 ÷ (진입가 - 손절가)

이 계산도 갭 손실과 거래비용을 완전히 막지는 못합니다. 예상 손절액에 슬리피지 여유를 더하고, 종목당·업종당 동시 노출 한도를 별도로 둬야 합니다. 자세한 계산은 포지션 사이징 계산법: 1% 룰을 적용하기 전에 알아야 할 것에서 이어집니다.

어떤 손절이 좋은지는 목적에 따라 달라집니다

  • 초기 손절은 진입 가설이 빠르게 틀렸을 때 한 거래의 손실 확대를 줄이는 데 가깝습니다.
  • 추적 손절은 보유 중 생긴 이익을 일부 지키지만 정상적인 조정에도 자주 청산될 수 있습니다.
  • 시간 손절은 가격보다 자본의 점유 기간을 관리합니다.
  • 손실 제한 없음은 반등 기회를 남기지만 큰 손실과 긴 보유를 감당할 자본이 필요합니다.

이번 결과에서 손실 제한 없음의 승률이 가장 높았다는 이유만으로 손절을 제거할 수는 없습니다. 반대로 추적 손절의 MDD가 가장 작았다는 이유만으로 가장 좋은 전략이라고 말하기도 어렵습니다. 목표가 누적수익률인지, 낙폭 제한인지, 자본 회전인지에 따라 선택이 달라집니다.

MDD 뜻과 계산법칼마 비율을 함께 보면 높은 수익률이 감수한 낙폭에 비해 충분했는지 비교할 수 있습니다.

손절 규칙을 정하기 전에 확인할 것

  • 손절이 없을 때의 최악 거래와 최대 보유 손실을 먼저 확인합니다.
  • 손절 신호 가격이 아니라 다음 체결 가능 가격으로 다시 계산합니다.
  • 손절 뒤 재진입을 허용하는지 명시하고 거래비용을 포함합니다.
  • 손절 거리와 포지션 크기를 같은 위험 예산으로 연결합니다.
  • 한두 개의 회복 사례나 급락 사례로 전체 규칙을 고르지 않습니다.
  • 고정 비율 하나만 고르지 말고 인접한 손절 거리에서도 관계가 유지되는지 확인합니다.

이번 비교의 결론은 손절이 수익률을 자동으로 높인다는 것이 아닙니다. 손절은 큰 회복을 일부 포기하는 대신 손실의 깊이와 자본 점유를 관리하는 규칙입니다. 누적수익률, MDD, 최악 거래와 체결 오차를 함께 보고 계좌가 감당할 수 있는 규칙을 선택해야 합니다.


이 글은 공개 가격 데이터를 이용한 과거 검증 예시입니다. 특정 종목의 매수·매도 추천이나 미래 수익을 의미하지 않으며, 실제 투자 판단과 책임은 투자자 본인에게 있습니다.