오르던 종목이 이동평균선까지 내려오면 가격이 싸진 것처럼 보입니다. 여기서 다시 반등하면 눌림목이고, 더 밀리면 하락의 시작이었다고 나중에 말하기 쉽습니다. 문제는 주문을 넣는 순간에는 둘을 구분할 수 없다는 데 있습니다.
그래서 눌림목을 차트 모양이 아니라 네 가지 조건으로 나눴습니다. 장기 추세가 여전히 상승하는지, 고점에서 얼마나 되돌렸는지, 단기 이동평균선을 회복했는지, 조정 중 거래량이 줄었는지를 순서대로 확인했습니다. 진입 뒤에는 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까지 계산합니다.
필요한 패키지는 한 번만 설치합니다.
python -m pip install pandas yfinance
"""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 뜻과 계산법: 낙폭이 작아도 회복이 오래 걸리는 이유처럼 누적수익률과 낙폭을 함께 봐야 읽을 수 있습니다. 필터의 역할을 ‘수익률을 높이는 장치’로 고정하면 추세 조건은 실패처럼 보입니다. 반대로 감수할 낙폭과 거래 빈도를 줄이는 목적이라면 다른 의미가 생깁니다.
성공 사례: 조정 뒤 장기 추세가 이어진 한미반도체
상승 추세와 거래량 수축을 모두 확인한 거래 중 가장 큰 수익은 한미반도체에서 나왔습니다. 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%로 수축 조건도 만족했습니다. 반등과 거래량만 보면 눌림목처럼 보일 수 있는 자리였습니다.

하지만 가격은 하락 중인 120일 SMA 아래에 있었습니다. 장기 추세 조건을 만족하지 못했는데도 반등만 보고 5월 3일 시가에 진입했다고 가정하면, 5월 13일 종가에 8% 손절선이 확인되고 다음 날 시가에 청산됩니다. 비용 차감 후 거래수익률은 -11.5%였습니다.
8% 손절 규칙이 실제 손실을 8%로 고정하지 못한 점도 중요합니다. 손절은 종가에 확인되고 주문은 다음 날 시가에 체결됩니다. 그 사이 가격이 더 낮게 시작하면 손실은 기준보다 커질 수 있습니다. 손절선은 최대 손실 보증이 아니라 주문을 내는 조건입니다.
이 사례에서는 추세 필터가 진입 자체를 막았습니다. 그렇다고 모든 하락 추세 반등이 실패한다는 뜻은 아닙니다. 전체 결과에서 추세 필터의 누적수익률이 더 낮았던 것처럼, 위험 신호를 거르는 과정에서 수익 기회도 함께 제외됩니다.
손실 거래가 많은데 누적수익률은 왜 플러스일까
세 규칙의 손실 거래 비율은 모두 70%를 넘었습니다. 이것만 보면 사용할 수 없는 전략처럼 보이지만, 승률만으로는 거래 손익의 크기를 알 수 없습니다. 짧은 EMA 재이탈에서 작은 손실을 반복하고, 한미반도체 사례처럼 보유 한도까지 이어지는 일부 거래가 여러 손실을 만회했습니다.
반대로 이런 큰 추세가 표본에서 한두 번 빠지면 전체 결과가 크게 달라질 수 있습니다. 그래서 +42.2%라는 누적수익률보다 다음 질문이 먼저입니다.
- 일부 큰 수익 거래를 제외해도 결과가 유지되는가?
- 손실이 연속으로 발생할 때 계좌가 감당할 수 있는가?
- 종목 수와 기간을 바꿔도 MDD 감소가 반복되는가?
- 실제 체결 비용과 동시 보유 한도를 넣어도 거래 가능한가?
돌파매매는 언제 실패할까? 돈치안 채널을 규칙으로 바꾸는 법에서도 낮은 승률과 큰 추세 한 번의 관계를 다른 진입 방식으로 비교합니다. 두 전략 모두 신호의 적중률보다 손실의 크기와 큰 수익 거래를 보유하는 규칙이 중요합니다.
이 결과를 그대로 일반화하면 안 되는 이유
이번 비교는 눌림목의 구성 요소를 하나씩 분리하기 위한 기본 검증입니다. 실제 운용 성과로 해석하기 전에는 다음 한계를 확인해야 합니다.
- 30종목은 가격 이력이 충분한 종목으로 구성돼 있어 상장폐지와 과거 지수 편입·편출을 모두 반영하지 못했습니다. 생존자 편향이 남아 있습니다.
- 종목마다 독립적으로 같은 금액을 배분했습니다. 실제 계좌의 동시 보유 수, 현금 비중과 업종 쏠림은 반영하지 않았습니다.
- 편도 0.15% 비용은 단순화한 가정입니다. 세금 변화, 호가 간격, 주문 크기와 유동성에 따라 실제 체결 비용은 달라집니다.
- 3~15% 되돌림, 120일 추세, 거래량 85%, 8% 손절은 설명 가능한 출발값입니다. 여러 값을 반복 대입해 가장 높은 결과를 고른 최적 조합이 아닙니다.
- 같은 이동평균 조건도 급격한 갭과 기업 이벤트를 막지 못합니다. 일봉 백테스트는 장중 체결 순서와 주문 미체결을 보여 주지 못합니다.
백테스트 결과를 믿기 전 확인할 3가지: 미래참조·생존자 편향·과최적화에서 전략 결과를 과장하기 쉬운 오류를 함께 확인할 수 있습니다.
눌림목에서 먼저 정해야 할 것
이번 결과를 한 문장으로 줄이면, 눌림목은 싸게 보이는 가격이 아니라 상승 가정이 아직 유효한 조정입니다.
- 20일 EMA 회복은 진입 시점을 정하지만 장기 추세를 대신하지 못합니다.
- 120일 상승 추세 조건은 낙폭을 줄였지만 수익 기회도 함께 줄였습니다.
- 거래량 수축은 이 표본에서 거래 수와 MDD를 더 낮췄지만, 손실 거래 비율을 크게 개선하지 못했습니다.
- 8% 손절도 다음 날 갭 때문에 실제 손실을 8%로 고정하지 못합니다.
따라서 진입 조건을 늘리기 전에 어떤 위험을 줄이려는 필터인지 정해야 합니다. 손절 거리와 계좌 위험으로 실제 수량을 계산하는 방법은 포지션 사이징 계산법: 1% 룰을 적용하기 전에 알아야 할 것에서 이어집니다.
이 글은 공개 가격 데이터를 이용한 과거 검증 예시입니다. 특정 종목의 매수·매도 추천이나 미래 수익을 의미하지 않으며, 실제 투자 판단과 책임은 투자자 본인에게 있습니다.