눌림목 매수는 왜 계속 물릴까? 추세·거래량 조건을 검증해봤다

오르던 종목이 이동평균선까지 내려오면 가격이 싸진 것처럼 보입니다. 여기서 다시 반등하면 눌림목이고, 더 밀리면 하락의 시작이었다고 나중에 말하기 쉽습니다. 문제는 주문을 넣는 순간에는 둘을 구분할 수 없다는 데 있습니다.

그래서 눌림목을 차트 모양이 아니라 네 가지 조건으로 나눴습니다. 장기 추세가 여전히 상승하는지, 고점에서 얼마나 되돌렸는지, 단기 이동평균선을 회복했는지, 조정 중 거래량이 줄었는지를 순서대로 확인했습니다. 진입 뒤에는 20일 EMA 재이탈, 8% 손절, 40거래일 보유 한도를 적용했습니다.

이 글의 핵심 결론

  • 20일 EMA 회복만 보고 진입하면 누적수익률은 +44.7%, MDD는 -20.3%였습니다.
  • 120일 상승 추세를 확인하자 누적수익률은 +34.8%로 낮아졌지만, MDD는 -11.6%로 줄었습니다.
  • 조정 구간의 거래량 수축까지 확인하면 거래 수가 3,830회에서 1,064회로 줄고 MDD는 -7.5%가 됐습니다.
  • 손실 거래 비율은 세 규칙 모두 73~75%였습니다. 필터는 적중률을 크게 높이기보다 거래 횟수와 낙폭을 줄였습니다.

눌림목을 주문 가능한 조건으로 바꾸기

‘상승 중 적당히 조정받은 종목’이라는 설명만으로는 같은 결과를 다시 만들 수 없습니다. 이번 비교에서는 다음 기준을 고정했습니다.

구성 요소 규칙 확인하려는 것
기준 고점 당일을 제외한 직전 60거래일 고가 이미 고점에서 너무 멀어진 가격을 제외합니다.
되돌림 범위 종가가 기준 고점보다 3~15% 낮음 너무 얕은 흔들림과 급격한 추락을 같은 눌림목으로 묶지 않습니다.
회복 신호 종가가 20일 EMA를 아래에서 위로 통과 하락 중 바로 사지 않고 단기 반등을 확인합니다.
상승 추세 종가가 120일 SMA 위이고, 120일 SMA가 20거래일 전보다 높음 장기 하락 속의 짧은 반등을 거릅니다.
거래량 수축 신호 전 3일 평균 거래량이 직전 20일 평균의 85% 미만 조정 구간에서 매도 참여가 줄었는지 확인합니다.
진입 신호 다음 거래일 시가 종가 확인 뒤 같은 종가에 산 것으로 처리하지 않습니다.
청산 20일 EMA 재이탈, 종가 -8% 손절, 40거래일 경과 중 먼저 발생한 조건 반등 가정이 틀리거나 추세가 늘어질 때 노출을 끝냅니다.
비용 매수·매도 각각 0.15% 수수료·세금·슬리피지를 단순화해 반영합니다.

거래량 조건에는 신호 당일 거래량을 넣지 않았습니다. 반등하는 날 거래량이 커진 뒤에야 ‘좋은 눌림목이었다’고 판단하면, 실제 주문 전에 알 수 없었던 정보를 조건에 섞을 수 있기 때문입니다. 신호 전 3거래일과 그 이전을 포함한 20거래일 평균만 비교했습니다.

SMA 계산법: 20일선은 정말 주가를 지지해줄까?EMA 계산법: SMA보다 빠르면 신호도 더 좋을까?에서 두 이동평균의 계산 차이를 먼저 확인할 수 있습니다.

신호부터 MDD까지 계산하는 코드

아래 코드는 yfinance 수정 일봉으로 30종목을 받고 세 가지 눌림목 규칙을 같은 기간에 비교합니다. 신호 다음 거래일 시가 체결, 양방향 비용, 청산, 자산 곡선과 MDD까지 계산합니다.

필요한 패키지는 한 번만 설치합니다.

TEXT
python -m pip install pandas yfinance
PYTHON
"""yfinance 수정 일봉으로 눌림목 매수의 추세·거래량 조건을 비교하는 본문 예제."""

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-01-01"
PULLBACK_HIGH_WINDOW = 60
TREND_WINDOW = 120
TREND_SLOPE_WINDOW = 20
VOLUME_SHORT_WINDOW = 3
VOLUME_LONG_WINDOW = 20
MAX_PULLBACK_RATE = 0.15
MIN_PULLBACK_RATE = 0.03
VOLUME_CONTRACTION_MAXIMUM = 0.85
STOP_LOSS_RATE = 0.08
MAX_HOLDING_DAYS = 40
ONE_WAY_COST_RATE = 0.0015


def add_signals(price_data: pd.DataFrame) -> pd.DataFrame:
    signal_data = price_data.copy()
    signal_data["ema20"] = signal_data["close"].ewm(span=20, adjust=False, min_periods=20).mean()
    signal_data["sma120"] = signal_data["close"].rolling(TREND_WINDOW).mean()
    signal_data["previous_high"] = signal_data["high"].shift(1).rolling(PULLBACK_HIGH_WINDOW).max()
    signal_data["pullback_rate"] = signal_data["close"] / signal_data["previous_high"] - 1.0
    signal_data["volume_ratio"] = signal_data["volume"].shift(1).rolling(VOLUME_SHORT_WINDOW).mean() / signal_data["volume"].shift(1).rolling(VOLUME_LONG_WINDOW).mean()

    recovery_signal = (signal_data["close"] > signal_data["ema20"]) & (signal_data["close"].shift(1) <= signal_data["ema20"].shift(1))
    pullback_range = signal_data["pullback_rate"].between(-MAX_PULLBACK_RATE, -MIN_PULLBACK_RATE)
    rising_trend = (signal_data["close"] > signal_data["sma120"]) & (signal_data["sma120"] > signal_data["sma120"].shift(TREND_SLOPE_WINDOW))
    contracting_volume = signal_data["volume_ratio"] < VOLUME_CONTRACTION_MAXIMUM

    signal_data["recovery_entry"] = recovery_signal & pullback_range
    signal_data["trend_entry"] = signal_data["recovery_entry"] & rising_trend
    signal_data["trend_volume_entry"] = signal_data["trend_entry"] & contracting_volume
    return signal_data.loc[TEST_START:TEST_END].dropna(subset=["ema20", "sma120", "previous_high"])


def run_backtest(signal_data: pd.DataFrame, entry_column: str) -> tuple[pd.Series, list[float]]:
    cash = 1.0
    position_shares = 0.0
    entry_price = 0.0
    entry_index = 0
    capital_before_entry = 0.0
    pending_order = ""
    trade_returns: list[float] = []
    equity_values: list[float] = []

    for row_index, (_, price_row) in enumerate(signal_data.iterrows()):
        if pending_order == "buy" and position_shares == 0.0:
            capital_before_entry = cash
            entry_price = float(price_row["open"])
            entry_index = row_index
            position_shares = cash * (1.0 - ONE_WAY_COST_RATE) / entry_price
            cash = 0.0
        elif pending_order == "sell" and position_shares > 0.0:
            exit_price = float(price_row["open"])
            cash = position_shares * exit_price * (1.0 - ONE_WAY_COST_RATE)
            trade_returns.append(cash / capital_before_entry - 1.0)
            position_shares = 0.0
        pending_order = ""

        if position_shares > 0.0:
            close_return = float(price_row["close"]) / entry_price - 1.0
            holding_days = row_index - entry_index
            if close_return <= -STOP_LOSS_RATE or float(price_row["close"]) < float(price_row["ema20"]) or holding_days >= MAX_HOLDING_DAYS:
                pending_order = "sell"
        elif bool(price_row[entry_column]):
            pending_order = "buy"
        equity_values.append(cash + position_shares * float(price_row["close"]))

    if position_shares > 0.0:
        cash = position_shares * float(signal_data.iloc[-1]["close"]) * (1.0 - ONE_WAY_COST_RATE)
        trade_returns.append(cash / capital_before_entry - 1.0)
        equity_values[-1] = cash
    return pd.Series(equity_values, index=signal_data.index), trade_returns


def run_buy_and_hold(signal_data: pd.DataFrame) -> pd.Series:
    entry_price = float(signal_data.iloc[0]["open"])
    position_shares = (1.0 - ONE_WAY_COST_RATE) / entry_price
    equity_curve = position_shares * signal_data["close"]
    equity_curve.iloc[-1] *= 1.0 - ONE_WAY_COST_RATE
    return equity_curve


def summarize_strategy(equity_curves: list[pd.Series], trade_returns: list[float] | None) -> dict[str, float | int | None]:
    portfolio_equity = pd.concat(equity_curves, axis=1).ffill().fillna(1.0).mean(axis=1)
    drawdown_curve = portfolio_equity / portfolio_equity.cummax() - 1.0
    summary: dict[str, float | int | None] = {
        "누적수익률": float(portfolio_equity.iloc[-1] - 1.0),
        "MDD": float(drawdown_curve.min()),
    }
    if trade_returns is not None:
        summary["거래 수"] = len(trade_returns)
        summary["손실 거래 비율"] = float(sum(trade_return < 0.0 for trade_return in trade_returns) / len(trade_returns))
    return summary


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


def build_summary() -> pd.DataFrame:
    strategy_columns = {
        "반등만 확인": "recovery_entry",
        "상승 추세 확인": "trend_entry",
        "상승 추세·거래량 수축 확인": "trend_volume_entry",
    }
    strategy_equity = {strategy_name: [] for strategy_name in strategy_columns}
    strategy_returns = {strategy_name: [] for strategy_name in strategy_columns}
    buy_and_hold_equity = []

    for ticker in TICKERS:
        signal_data = add_signals(download_price_data(ticker))
        buy_and_hold_equity.append(run_buy_and_hold(signal_data))
        for strategy_name, entry_column in strategy_columns.items():
            equity_curve, trade_returns = run_backtest(signal_data, entry_column)
            strategy_equity[strategy_name].append(equity_curve)
            strategy_returns[strategy_name].extend(trade_returns)

    summary_rows = [
        {"전략": strategy_name, **summarize_strategy(strategy_equity[strategy_name], strategy_returns[strategy_name])}
        for strategy_name in strategy_columns
    ]
    summary_rows.append({"전략": "동일 종목 매수 후 보유", **summarize_strategy(buy_and_hold_equity, None)})
    return pd.DataFrame(summary_rows)


def main() -> None:
    summary = build_summary()
    print(summary.to_string(index=False, formatters={"누적수익률": "{:+.1%}".format, "MDD": "{:.1%}".format, "손실 거래 비율": "{:.1%}".format}))


if __name__ == "__main__":
    main()

shift(1)은 기준 고점과 거래량을 신호 전에 확정하는 역할을 합니다. pending_order는 장 마감 뒤 확인한 신호를 다음 거래일 시가 주문으로 넘깁니다. 이 두 부분을 빼면 과거 차트에서는 가능해 보여도 실제로는 체결할 수 없는 결과가 섞일 수 있습니다.

추세 필터는 수익률보다 낙폭을 줄였다

수정 일봉을 2014년 1월 2일부터 2025년 12월 30일까지 계산했습니다. 종목별로 같은 금액을 배분했다고 가정한 뒤 일별 자산을 평균했고, 같은 종목을 처음부터 끝까지 보유한 결과도 함께 놓았습니다.

전략 누적수익률 MDD 거래 수 손실 거래 비율
반등만 확인 +44.7% -20.3% 3,808회 74.3%
상승 추세 확인 +34.8% -11.6% 1,990회 75.0%
상승 추세·거래량 수축 확인 +42.2% -7.5% 1,064회 73.5%
동일 종목 매수 후 보유 +1,110.9% -43.1%
눌림목 조건별 자산 곡선과 낙폭 비교

매수 후 보유는 누적수익률에서 세 규칙을 크게 앞섰습니다. 대신 MDD도 -43.1%로 가장 컸습니다. 눌림목 규칙은 상승 전체를 보유하는 방법이 아니라 조건이 맞을 때만 짧게 노출되는 방법이므로, 수익률만 놓고 매수 후 보유를 대체했다고 말할 수 없습니다.

눈에 띄는 부분은 추세 필터의 대가입니다. 120일 SMA의 위치와 방향을 확인하자 거래는 약 48% 줄고 MDD는 8.7%포인트 낮아졌지만, 누적수익률도 줄었습니다. 하락 추세의 손실 신호뿐 아니라 이후 반등한 거래도 함께 제외됐기 때문입니다.

거래량 수축까지 확인한 규칙은 반등만 확인한 규칙보다 거래를 약 72% 줄이면서 MDD를 12.8%포인트 낮췄습니다. 다만 손실 거래 비율은 73.5%로 여전히 높았습니다. 필터를 붙였다고 신호 하나하나가 잘 맞게 된 것은 아닙니다.

반등, 상승 추세, 상승 추세와 거래량 수축 조건의 누적수익률 및 MDD 비교

이 결과는 MDD 뜻과 계산법: 낙폭이 작아도 회복이 오래 걸리는 이유처럼 누적수익률과 낙폭을 함께 봐야 읽을 수 있습니다. 필터의 역할을 ‘수익률을 높이는 장치’로 고정하면 추세 조건은 실패처럼 보입니다. 반대로 감수할 낙폭과 거래 빈도를 줄이는 목적이라면 다른 의미가 생깁니다.

성공 사례: 조정 뒤 장기 추세가 이어진 한미반도체

상승 추세와 거래량 수축을 모두 확인한 거래 중 가장 큰 수익은 한미반도체에서 나왔습니다. 2024년 2월 2일 종가에 신호가 확정됐고, 다음 거래일인 2월 5일 시가에 진입했습니다. 신호 시점의 종가는 직전 60일 고점보다 12.5% 낮았고, 조정 구간의 거래량 비율은 75.8%였습니다.

한미반도체의 상승 추세 눌림목과 다음 시가 진입 및 청산

진입 당시 가격은 상승 중인 120일 SMA 위에 있었습니다. 20일 EMA를 회복한 뒤 상승이 이어졌고, 40거래일 보유 한도가 종가에 확인된 다음 날 시가에 청산해 거래수익률은 비용 차감 후 +120.0%였습니다. 신호 확인일과 실제 청산일이 한 거래일 차이 나므로 보유 일수는 41거래일로 집계됩니다.

이 한 번의 큰 거래가 낮은 승률에도 전체 결과가 플러스를 유지한 이유를 보여 줍니다. 눌림목 규칙도 모든 반등을 맞히는 전략이 아니라, 작은 손실을 반복하면서 드물게 이어지는 추세를 보유하는 구조에 가깝습니다.

실패 사례: 거래량은 줄었지만 추세가 이미 꺾인 JYP Ent.

2024년 5월 2일 JYP Ent.도 20일 EMA를 회복했습니다. 종가는 직전 60일 고점보다 14.7% 낮았고, 조정 구간 거래량 비율은 60.6%로 수축 조건도 만족했습니다. 반등과 거래량만 보면 눌림목처럼 보일 수 있는 자리였습니다.

JYP Ent.의 하락 추세 반등과 다음 시가 진입 및 손절

하지만 가격은 하락 중인 120일 SMA 아래에 있었습니다. 장기 추세 조건을 만족하지 못했는데도 반등만 보고 5월 3일 시가에 진입했다고 가정하면, 5월 13일 종가에 8% 손절선이 확인되고 다음 날 시가에 청산됩니다. 비용 차감 후 거래수익률은 -11.5%였습니다.

8% 손절 규칙이 실제 손실을 8%로 고정하지 못한 점도 중요합니다. 손절은 종가에 확인되고 주문은 다음 날 시가에 체결됩니다. 그 사이 가격이 더 낮게 시작하면 손실은 기준보다 커질 수 있습니다. 손절선은 최대 손실 보증이 아니라 주문을 내는 조건입니다.

이 사례에서는 추세 필터가 진입 자체를 막았습니다. 그렇다고 모든 하락 추세 반등이 실패한다는 뜻은 아닙니다. 전체 결과에서 추세 필터의 누적수익률이 더 낮았던 것처럼, 위험 신호를 거르는 과정에서 수익 기회도 함께 제외됩니다.

손실 거래가 많은데 누적수익률은 왜 플러스일까

세 규칙의 손실 거래 비율은 모두 70%를 넘었습니다. 이것만 보면 사용할 수 없는 전략처럼 보이지만, 승률만으로는 거래 손익의 크기를 알 수 없습니다. 짧은 EMA 재이탈에서 작은 손실을 반복하고, 한미반도체 사례처럼 보유 한도까지 이어지는 일부 거래가 여러 손실을 만회했습니다.

반대로 이런 큰 추세가 표본에서 한두 번 빠지면 전체 결과가 크게 달라질 수 있습니다. 그래서 +42.2%라는 누적수익률보다 다음 질문이 먼저입니다.

  1. 일부 큰 수익 거래를 제외해도 결과가 유지되는가?
  2. 손실이 연속으로 발생할 때 계좌가 감당할 수 있는가?
  3. 종목 수와 기간을 바꿔도 MDD 감소가 반복되는가?
  4. 실제 체결 비용과 동시 보유 한도를 넣어도 거래 가능한가?

돌파매매는 언제 실패할까? 돈치안 채널을 규칙으로 바꾸는 법에서도 낮은 승률과 큰 추세 한 번의 관계를 다른 진입 방식으로 비교합니다. 두 전략 모두 신호의 적중률보다 손실의 크기와 큰 수익 거래를 보유하는 규칙이 중요합니다.

이 결과를 그대로 일반화하면 안 되는 이유

이번 비교는 눌림목의 구성 요소를 하나씩 분리하기 위한 기본 검증입니다. 실제 운용 성과로 해석하기 전에는 다음 한계를 확인해야 합니다.

  • 30종목은 가격 이력이 충분한 종목으로 구성돼 있어 상장폐지와 과거 지수 편입·편출을 모두 반영하지 못했습니다. 생존자 편향이 남아 있습니다.
  • 종목마다 독립적으로 같은 금액을 배분했습니다. 실제 계좌의 동시 보유 수, 현금 비중과 업종 쏠림은 반영하지 않았습니다.
  • 편도 0.15% 비용은 단순화한 가정입니다. 세금 변화, 호가 간격, 주문 크기와 유동성에 따라 실제 체결 비용은 달라집니다.
  • 3~15% 되돌림, 120일 추세, 거래량 85%, 8% 손절은 설명 가능한 출발값입니다. 여러 값을 반복 대입해 가장 높은 결과를 고른 최적 조합이 아닙니다.
  • 같은 이동평균 조건도 급격한 갭과 기업 이벤트를 막지 못합니다. 일봉 백테스트는 장중 체결 순서와 주문 미체결을 보여 주지 못합니다.

백테스트 결과를 믿기 전 확인할 3가지: 미래참조·생존자 편향·과최적화에서 전략 결과를 과장하기 쉬운 오류를 함께 확인할 수 있습니다.

눌림목에서 먼저 정해야 할 것

이번 결과를 한 문장으로 줄이면, 눌림목은 싸게 보이는 가격이 아니라 상승 가정이 아직 유효한 조정입니다.

  • 20일 EMA 회복은 진입 시점을 정하지만 장기 추세를 대신하지 못합니다.
  • 120일 상승 추세 조건은 낙폭을 줄였지만 수익 기회도 함께 줄였습니다.
  • 거래량 수축은 이 표본에서 거래 수와 MDD를 더 낮췄지만, 손실 거래 비율을 크게 개선하지 못했습니다.
  • 8% 손절도 다음 날 갭 때문에 실제 손실을 8%로 고정하지 못합니다.

따라서 진입 조건을 늘리기 전에 어떤 위험을 줄이려는 필터인지 정해야 합니다. 손절 거리와 계좌 위험으로 실제 수량을 계산하는 방법은 포지션 사이징 계산법: 1% 룰을 적용하기 전에 알아야 할 것에서 이어집니다.


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