가격이 평균에서 크게 내려오면 반등을 기대하게 됩니다. 평소보다 멀리 떨어졌으니 다시 평균으로 돌아갈 가능성이 높아 보이기 때문입니다. 하지만 과매도는 가격이 많이 내려왔다는 뜻일 뿐, 하락이 끝났다는 뜻은 아닙니다.
평균회귀 전략이 어려운 지점도 여기 있습니다. 상승 추세의 짧은 조정과 하락 추세의 중간 반등은 같은 과매도 수치로 나타날 수 있습니다. 이번에는 Z-스코어 -2 이하를 같은 진입 신호로 사용하고, 120일 상승 추세를 확인했을 때 무엇이 달라지는지 비교했습니다.
이 글의 핵심 결론
- 과매도만 확인한 규칙은 누적수익률 +54.3%, MDD -17.8%를 기록했습니다.
- 상승 추세 필터를 적용하면 누적수익률은 +12.4%로 낮아졌지만 MDD도 -8.3%로 줄었습니다.
- 거래 수는 1,617회에서 410회로 감소했지만 승률은 59.0%와 59.3%로 거의 같았습니다.
- 필터 전략의 최장 회복 기간은 오히려 581일에서 1,981일로 길어졌습니다. 낙폭이 작다고 반드시 빨리 회복하는 것은 아니었습니다.
과매도를 평균과의 거리로 정의하기
Z-스코어는 현재 종가가 최근 평균에서 표준편차 몇 배만큼 떨어져 있는지를 나타냅니다.
Z-스코어 = (종가 - 20일 평균) ÷ 20일 표준편차
이번 전략은 Z-스코어가 위에서 아래로 -2를 처음 통과한 날을 과매도 신호로 봅니다. 이미 -2 아래에 머무는 동안 매일 반복 진입하지 않고, 경계를 새로 통과했을 때만 주문합니다.
| 구성 요소 | 규칙 | 역할 |
|---|---|---|
| 과매도 신호 | Z-스코어가 -2 이하로 처음 진입 | 최근 20일 평균에서 크게 이탈한 날을 찾습니다. |
| 추세 필터 | 종가가 120일 SMA 위이고 120일 SMA가 20거래일 전보다 높음 | 장기 하락 중의 과매도를 제외합니다. |
| 진입 | 신호 다음 거래일 시가 | 종가 신호를 같은 종가에 체결하지 않습니다. |
| 평균 회복 청산 | 종가가 20일 SMA 이상이면 다음 시가 청산 | 처음 세운 평균회귀 가정이 실현되면 종료합니다. |
| 손절 | 진입가 대비 종가 -10% 이하이면 다음 시가 청산 | 평균에서 계속 멀어지는 손실을 제한합니다. |
| 시간 청산 | 진입 후 10거래일이 지나면 다음 시가 청산 | 반등하지 않는 포지션을 계속 보유하지 않습니다. |
| 비용 | 매수·매도 각각 0.15% | 수수료·세금·슬리피지를 단순화해 반영합니다. |
Z-스코어 계산법: 과매도라고 바로 반등하지 않는 이유에서 평균과 표준편차를 이용한 계산과 과매도 해석을 더 자세히 볼 수 있습니다.
추세 필터 유무를 비교하는 코드
아래 코드는 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"
ZSCORE_WINDOW = 20
ZSCORE_ENTRY_LEVEL = -2.0
TREND_WINDOW = 120
TREND_SLOPE_WINDOW = 20
STOP_LOSS_RATE = 0.10
MAX_HOLDING_DAYS = 10
ONE_WAY_COST_RATE = 0.0015
def add_signals(price_data: pd.DataFrame) -> pd.DataFrame:
signal_data = price_data.copy()
signal_data["sma20"] = signal_data["close"].rolling(ZSCORE_WINDOW).mean()
signal_data["std20"] = signal_data["close"].rolling(ZSCORE_WINDOW).std(ddof=0)
signal_data["zscore"] = (signal_data["close"] - signal_data["sma20"]) / signal_data["std20"]
signal_data["sma120"] = signal_data["close"].rolling(TREND_WINDOW).mean()
oversold_entry = (signal_data["zscore"] <= ZSCORE_ENTRY_LEVEL) & (signal_data["zscore"].shift(1) > ZSCORE_ENTRY_LEVEL)
rising_trend = (signal_data["close"] > signal_data["sma120"]) & (signal_data["sma120"] > signal_data["sma120"].shift(TREND_SLOPE_WINDOW))
signal_data["oversold_entry"] = oversold_entry
signal_data["trend_entry"] = oversold_entry & rising_trend
return signal_data.loc[TEST_START:TEST_END].dropna(subset=["zscore", "sma120"])
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["sma20"]) 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 calculate_max_recovery_days(equity_curve: pd.Series) -> int:
underwater = equity_curve < equity_curve.cummax()
longest_days = 0
underwater_start = None
for trade_day, is_underwater in underwater.items():
if is_underwater and underwater_start is None:
underwater_start = trade_day
elif not is_underwater and underwater_start is not None:
longest_days = max(longest_days, (trade_day - underwater_start).days)
underwater_start = None
if underwater_start is not None:
longest_days = max(longest_days, (equity_curve.index[-1] - underwater_start).days)
return longest_days
def summarize_strategy(equity_curves: list[pd.Series], trade_returns: list[float]) -> dict[str, float | int]:
portfolio_equity = pd.concat(equity_curves, axis=1).ffill().fillna(1.0).mean(axis=1)
drawdown_curve = portfolio_equity / portfolio_equity.cummax() - 1.0
return {
"누적수익률": float(portfolio_equity.iloc[-1] - 1.0),
"MDD": float(drawdown_curve.min()),
"최장 회복일": calculate_max_recovery_days(portfolio_equity),
"거래 수": len(trade_returns),
"승률": float(sum(trade_return > 0.0 for trade_return in trade_returns) / len(trade_returns)),
}
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 = {"과매도만 확인": "oversold_entry", "상승 추세 확인": "trend_entry"}
equity_by_strategy = {strategy_name: [] for strategy_name in strategy_columns}
returns_by_strategy = {strategy_name: [] for strategy_name in strategy_columns}
for ticker in TICKERS:
signal_data = add_signals(download_price_data(ticker))
for strategy_name, entry_column in strategy_columns.items():
equity_curve, trade_returns = run_backtest(signal_data, entry_column)
equity_by_strategy[strategy_name].append(equity_curve)
returns_by_strategy[strategy_name].extend(trade_returns)
return pd.DataFrame(
[
{"전략": strategy_name, **summarize_strategy(equity_by_strategy[strategy_name], returns_by_strategy[strategy_name])}
for strategy_name in strategy_columns
]
)
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)은 오늘의 120일 평균과 20거래일 전 값을 비교할 때 미래 가격이 섞이지 않게 합니다. pending_order는 종가에 확인한 과매도와 청산 신호를 다음 거래일 시가 주문으로 넘깁니다.
추세 필터는 수익 기회와 낙폭을 함께 줄였다
2014년 1월 2일부터 2025년 12월 30일까지 수정 일봉 30종목을 같은 비중으로 계산했습니다.
| 전략 | 누적수익률 | MDD | 최장 회복 기간 | 거래 수 | 승률 |
|---|---|---|---|---|---|
| 과매도만 확인 | +54.3% | -17.8% | 581일 | 1,617회 | 59.0% |
| 상승 추세 확인 | +12.4% | -8.3% | 1,981일 | 410회 | 59.3% |

추세 필터는 MDD를 9.5%포인트 낮췄습니다. 하락 추세의 과매도 신호를 제외하면서 급격한 손실 구간이 줄어든 결과입니다. 그러나 누적수익률도 +54.3%에서 +12.4%로 낮아졌습니다. 하락 추세에서 빠르게 반등한 거래까지 함께 제외했기 때문입니다.
승률은 거의 달라지지 않았습니다. 상승 추세를 확인한다고 개별 과매도 신호가 더 자주 성공한 것은 아닙니다. 필터가 바꾼 것은 적중률보다 시장에 노출되는 횟수와 큰 손실의 빈도였습니다.

가장 의외인 결과는 회복 기간입니다. 과매도만 확인한 전략은 2021년 12월 말 시작된 수중 구간에서 약 581일 뒤 이전 고점을 회복했습니다. 상승 추세 필터 전략은 2020년 3월부터 2025년 8월까지 약 1,981일 동안 이전 고점을 넘지 못했습니다.
필터 전략이 그 기간 내내 큰 손실을 낸 것은 아닙니다. 거래가 410회로 줄어 자산 곡선이 오랫동안 정체했고, 작은 낙폭을 만회할 수익 기회도 적었습니다. MDD는 손실의 깊이를 보여 주지만 회복 속도까지 알려 주지는 않습니다.
성공 사례: 상승 추세 안에서 평균을 회복한 셀트리온
2018년 2월 7일 셀트리온의 Z-스코어는 -2.14까지 내려왔습니다. 가격은 상승 중인 120일 SMA 위에 있었으므로 다음 거래일 시가 186,033원에 진입했습니다.

가격은 세 거래일 뒤 20일 평균을 회복했고, 2월 13일 시가 226,526원에 청산한 거래수익률은 비용 차감 후 +21.4%였습니다. 장기 추세는 유지되면서 단기 가격만 평균에서 멀어진 전형적인 평균회귀 구조였습니다.
이 사례가 보여 주는 것은 Z-스코어 -2가 반등을 예측했다는 뜻이 아닙니다. 같은 과매도 중에서도 장기 상승 구조가 남아 있었고, 정해 둔 20일 평균 회복 조건이 짧은 기간 안에 충족됐습니다.
실패 사례: 과매도 뒤에도 하락이 이어진 포스코퓨처엠
2024년 11월 8일 포스코퓨처엠의 Z-스코어는 -2.07이었습니다. 과매도 조건만 보면 진입 대상이지만, 가격은 하락 중인 120일 SMA 아래에 있었습니다.

11월 11일 시가 212,745원에 진입했다고 가정하면 하락은 멈추지 않았습니다. 종가 기준 10% 손절 조건이 먼저 발생했고, 11월 15일 시가 173,492원에 청산한 거래수익률은 -18.7%였습니다.
10% 손절선이 실제 손실을 10%로 막지 못한 이유는 신호와 체결 사이의 갭입니다. 종가에 손절 조건을 확인한 뒤 다음 시가에 주문했기 때문에, 밤사이 가격이 더 내려가면 손실은 기준보다 커집니다.
이 거래는 상승 추세 필터에서 제외됐습니다. 다만 추세 필터가 모든 실패를 막은 것은 아닙니다. 필터 전략에서도 410건 중 51건이 10% 손절 조건으로 끝났고, 승률도 과매도 단독 규칙과 거의 같았습니다.
과매도는 반등 신호가 아니라 상태다
Z-스코어 -2는 가격이 최근 분포에서 멀리 떨어졌다는 뜻입니다. 그 거리가 어떤 과정으로 만들어졌는지는 말하지 않습니다.
- 상승 추세의 단기 차익 실현도 Z-스코어 -2가 될 수 있습니다.
- 실적 충격이나 업황 악화가 시작된 하락도 같은 값이 될 수 있습니다.
- 변동성이 갑자기 커지면 평균과 표준편차가 뒤늦게 움직여 신호가 연속으로 바뀔 수 있습니다.
- 가격 분포가 정규분포라는 보장이 없으므로 -2를 고정된 반등 확률로 해석할 수 없습니다.
RSI 계산법: 70이면 팔고 30이면 사도 될까?에서도 과매도 수치가 곧바로 매수 명령이 되지 않는 이유를 다른 계산 방식으로 확인할 수 있습니다.
필터를 추가하기 전에 정해야 할 목적
이번 결과에서 상승 추세 필터는 수익률을 높이는 장치가 아니었습니다. 거래 횟수와 MDD를 줄이는 대신 수익 기회와 회복 속도를 포기했습니다.
따라서 필터를 평가할 때는 다음 질문을 분리해야 합니다.
- 누적수익률을 높이려는 필터인가, 큰 낙폭을 줄이려는 필터인가?
- 거래가 줄어든 뒤에도 이전 고점을 회복할 수익 기회가 충분한가?
- 하락 추세의 빠른 반등을 포기해도 운용 목적과 맞는가?
- 손절 기준보다 불리한 다음 시가 체결을 계좌가 감당할 수 있는가?
- 종목과 기간을 바꿔도 MDD 감소와 긴 회복의 관계가 유지되는가?
MDD 뜻과 계산법: 낙폭이 작아도 회복이 오래 걸리는 이유에서 이번 결과처럼 낙폭의 크기와 회복 기간이 다르게 움직이는 경우를 더 자세히 다룹니다.
이번 검증의 한계
- 30종목은 가격 이력이 충분한 종목으로 구성돼 있어 상장폐지와 과거 지수 편입·편출을 모두 반영하지 못했습니다.
- 종목별로 독립적인 한 단위 자산을 운용했습니다. 동시 보유 수와 계좌 현금 한도는 반영하지 않았습니다.
- 편도 0.15% 비용은 단순화한 값이며 급락일의 호가 공백과 주문 미체결은 더 큰 손실을 만들 수 있습니다.
- Z-스코어 -2, 120일 추세, 10% 손절과 10거래일 보유는 가설을 비교하기 위한 출발값입니다.
- 최장 회복 기간은 포트폴리오가 이전 고점을 다시 넘기까지의 달력 일수이며, 해당 기간 내내 포지션을 보유했다는 뜻은 아닙니다.
백테스트 결과를 믿기 전 확인할 3가지: 미래참조·생존자 편향·과최적화도 실제 적용 전에 함께 확인하는 편이 좋습니다.
하락 추세에서는 싸다는 이유만으로 부족하다
이번 결과를 한 문장으로 줄이면, 평균에서 멀어진 가격과 평균으로 돌아올 가격은 같은 말이 아닙니다.
- 과매도 단독 규칙은 더 높은 수익률과 더 큰 낙폭을 함께 만들었습니다.
- 상승 추세 필터는 MDD를 줄였지만 승률을 높이지 못했습니다.
- 거래가 줄어든 필터 전략은 작은 낙폭에도 이전 고점 회복이 더 오래 걸렸습니다.
- 종가 손절은 다음 시가의 갭 손실을 제한하지 못합니다.
앞선 눌림목 매수는 왜 계속 물릴까? 추세·거래량 조건을 검증해봤다에서도 추세 필터는 수익률보다 낙폭과 거래 수를 바꿨습니다. 평균회귀에서도 필터가 무엇을 얻고 무엇을 포기하는지 함께 봐야 합니다.
이 글은 공개 가격 데이터를 이용한 과거 검증 예시입니다. 특정 종목의 매수·매도 추천이나 미래 수익을 의미하지 않으며, 실제 투자 판단과 책임은 투자자 본인에게 있습니다.