가격이 신고가를 넘으면서 거래량까지 급증하면 강한 돌파처럼 보입니다. 시장 참여자가 늘었으니 추세도 이어질 것이라는 해석입니다. 하지만 거래량이 평소보다 몇 배 많다는 사실만으로 주문 가능한 유동성과 상승 지속을 보장할 수는 없습니다.
거래량 20만 주가 평소 2만 주인 종목에서는 10배이고, 평소 200만 주인 종목에서는 0.1배입니다. 상대 거래량은 이 차이를 잘 보여 주지만 실제 거래 가능한 금액은 말하지 않습니다. 반대로 거래대금이 충분해도 매수 이후 가격이 오르는 것은 아닙니다.
이번에는 가격 돌파, 상대 거래량 2배, 과거 평균 거래대금 100억 원, 120일 상승 추세를 차례로 추가했습니다. 각 필터가 수익률을 높였는지, 아니면 거래 횟수와 체결 위험만 줄였는지 분리했습니다.
이 글의 핵심 결론
- 가격 돌파만 사용한 규칙은 누적수익률 +125.1%, MDD -17.0%였습니다.
- 거래량 2배 조건을 붙이자 +87.6%, MDD -13.3%로 바뀌었지만 승률은 39.9%에서 39.4%로 낮아졌습니다.
- 거래대금과 상승 추세까지 확인한 규칙은 +59.4%, MDD -10.3%, 379회 거래를 기록했습니다.
- SOOP 사례에서는 상대 거래량이 19.7배였지만 평균 거래대금은 78.7억 원이었고, 거래수익률은 -13.8%였습니다. 높은 거래량 배수가 돌파 성공을 뜻하지 않았습니다.
거래량 배수와 거래대금을 따로 보기
상대 거래량은 오늘 거래량을 오늘 이전 20거래일 평균과 비교합니다.
상대 거래량 = 오늘 거래량 ÷ 직전 20거래일 평균 거래량
평균 거래대금 = 직전 20거래일의 종가 × 거래량 평균
당일 거래량이 20일 평균의 두 배 이상이면 거래량 돌파로 정의했습니다. 거래대금은 신호 당일 급증한 금액이 아니라, 신호 전에 확보할 수 있는 직전 20거래일 평균을 사용했습니다. 뉴스가 발생한 하루만 유동성이 커진 종목을 평소에도 주문 가능한 종목으로 오인하지 않기 위해서입니다.
| 단계 | 추가 조건 | 확인하려는 것 |
|---|---|---|
| 가격 돌파 | 종가가 직전 20일 고가를 처음 상향 돌파 | 가격이 새 범위를 시작했는지 확인합니다. |
| 거래량 돌파 | 상대 거래량 2배 이상 | 평소보다 참여가 증가했는지 확인합니다. |
| 거래대금 확인 | 직전 20일 평균 거래대금 100억 원 이상 | 한 번의 거래량 급증이 아니라 평소 주문 가능 규모를 봅니다. |
| 상승 추세 확인 | 종가가 상승 중인 120일 SMA 위 | 장기 하락 중의 일회성 급등을 제외합니다. |
진입은 모든 조건을 종가에 확인한 다음 거래일 시가로 통일했습니다. 청산은 직전 10일 저가 이탈, 종가 기준 10% 손절, 40거래일 경과 중 먼저 발생한 조건을 다음 시가에 실행했습니다. 매수와 매도에는 각각 0.15% 비용을 적용했습니다.
필터를 한 단계씩 비교하는 코드
아래 코드는 yfinance 수정 일봉으로 네 가지 규칙을 같은 종목과 기간에 계산합니다. 미래 참조를 막은 가격·거래량 기준, 다음 시가 체결, 비용, 청산과 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"
ENTRY_LOOKBACK_DAYS = 20
EXIT_LOOKBACK_DAYS = 10
RELATIVE_VOLUME_MINIMUM = 2.0
MINIMUM_AVERAGE_TRADING_VALUE = 10_000_000_000
TREND_WINDOW = 120
TREND_SLOPE_WINDOW = 20
STOP_LOSS_RATE = 0.10
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["entry_channel"] = signal_data["high"].shift(1).rolling(ENTRY_LOOKBACK_DAYS).max()
signal_data["exit_channel"] = signal_data["low"].shift(1).rolling(EXIT_LOOKBACK_DAYS).min()
signal_data["relative_volume"] = signal_data["volume"] / signal_data["volume"].shift(1).rolling(20).mean()
signal_data["average_trading_value"] = (signal_data["close"] * signal_data["volume"]).shift(1).rolling(20).mean()
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))
volume_breakout = price_breakout & (signal_data["relative_volume"] >= RELATIVE_VOLUME_MINIMUM)
liquid_market = signal_data["average_trading_value"] >= MINIMUM_AVERAGE_TRADING_VALUE
rising_trend = (signal_data["close"] > signal_data["sma120"]) & (signal_data["sma120"] > signal_data["sma120"].shift(TREND_SLOPE_WINDOW))
signal_data["price_entry"] = price_breakout
signal_data["volume_entry"] = volume_breakout
signal_data["liquid_entry"] = volume_breakout & liquid_market
signal_data["trend_entry"] = volume_breakout & liquid_market & rising_trend
signal_data["exit_signal"] = signal_data["close"] < signal_data["exit_channel"]
return signal_data.loc[TEST_START:TEST_END].dropna(subset=["entry_channel", "exit_channel", "sma120"])
def run_backtest(signal_data: pd.DataFrame, entry_column: str) -> tuple[pd.Series, list[float]]:
cash, position_shares, entry_price, capital_before_entry = 1.0, 0.0, 0.0, 0.0
entry_index, pending_order = 0, ""
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, entry_price, entry_index = cash, float(price_row["open"]), 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:
cash = position_shares * float(price_row["open"]) * (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
if close_return <= -STOP_LOSS_RATE or bool(price_row["exit_signal"]) or row_index - entry_index >= 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 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 = portfolio_equity / portfolio_equity.cummax() - 1.0
return {"누적수익률": float(portfolio_equity.iloc[-1] - 1.0), "MDD": float(drawdown.min()), "거래 수": len(trade_returns), "승률": float(sum(value > 0.0 for value in trade_returns) / len(trade_returns))}
def download_price_data(ticker: str) -> pd.DataFrame:
prices = yf.download(ticker, start=DOWNLOAD_START, end=TEST_END, auto_adjust=True, progress=False, multi_level_index=False, threads=False)
prices = prices.rename(columns=str.lower).dropna(subset=["open", "high", "low", "close"]).copy()
if len(prices) < 500:
raise RuntimeError(f"검증 데이터가 부족합니다: {ticker}")
return prices
def main() -> None:
strategy_columns = {"가격 돌파": "price_entry", "가격·거래량 돌파": "volume_entry", "거래대금 확인": "liquid_entry", "거래대금·상승 추세 확인": "trend_entry"}
equity_by_strategy = {name: [] for name in strategy_columns}
returns_by_strategy = {name: [] for 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, returns = run_backtest(signal_data, entry_column)
equity_by_strategy[strategy_name].append(equity)
returns_by_strategy[strategy_name].extend(returns)
summary = pd.DataFrame([{"전략": name, **summarize_strategy(equity_by_strategy[name], returns_by_strategy[name])} for name in strategy_columns])
print(summary.to_string(index=False, formatters={"누적수익률": "{:+.1%}".format, "MDD": "{:.1%}".format, "승률": "{:.1%}".format}))
if __name__ == "__main__":
main()
volume.shift(1).rolling(20)은 오늘 거래량을 평균 안에 섞지 않습니다. 평균 거래대금도 신호일 이전 데이터로 확정합니다. 현재 거래량과 현재 종가로 만든 거래대금을 유동성 기준에 넣으면 뉴스가 발생한 날의 결과를 미리 이용하게 됩니다.
거래량을 추가해도 승률은 높아지지 않았다
2014년 1월 2일부터 2025년 12월 30일까지 수정 일봉 30종목을 같은 비중으로 계산했습니다.
| 전략 | 누적수익률 | MDD | 거래 수 | 승률 |
|---|---|---|---|---|
| 가격 돌파 | +125.1% | -17.0% | 1,373회 | 39.9% |
| 가격·거래량 돌파 | +87.6% | -13.3% | 822회 | 39.4% |
| 거래대금 확인 | +64.3% | -12.3% | 606회 | 38.6% |
| 거래대금·상승 추세 확인 | +59.4% | -10.3% | 379회 | 40.1% |

거래량 2배 조건은 거래를 약 40% 줄이고 MDD를 3.7%포인트 낮췄습니다. 그러나 누적수익률도 줄었고 승률은 개선되지 않았습니다. 강한 거래량이 붙은 돌파만 남기면 신호 품질이 자동으로 좋아질 것이라는 가설은 이 표본에서 성립하지 않았습니다.
거래대금 100억 원 조건과 상승 추세를 더하면 거래 수는 379회까지 줄고 MDD는 -10.3%가 됐습니다. 수익률도 +59.4%로 낮아졌습니다. 필터가 수익을 높인 것이 아니라 시장 노출과 거래 기회를 함께 줄인 결과입니다.

유동성 필터에서 제외된 거래가 더 나쁘지는 않았다
가격·거래량 돌파 거래를 신호 시점의 평균 거래대금으로 다시 나누면 821건 중 237건이 100억 원 미만이었습니다. 이 저유동성 묶음의 평균 거래수익률은 +3.24%, 승률은 43.9%였습니다. 100억 원 이상 묶음은 평균 +2.49%, 승률 38.0%였습니다.
낮은 거래대금 신호가 오히려 더 좋은 수치를 보였다고 해서 저유동성 종목을 선택해야 한다는 뜻은 아닙니다. 일봉 백테스트는 주문 크기에 따른 가격 충격과 미체결을 반영하지 못합니다. 거래대금 필터의 역할은 과거 수익률을 높이는 데 있지 않고, 계좌 규모에 비해 주문 가능한 시장인지 확인하는 데 있습니다.
같은 100억 원 거래대금이라도 10만 원 주문과 1억 원 주문의 체결 위험은 다릅니다. 고정 임계값보다 주문 금액 ÷ 평균 거래대금 비율을 함께 보는 편이 실제 운용에 더 가깝습니다.
성공 사례: 거래량과 장기 추세가 이어진 원익IPS
2025년 9월 10일 원익IPS는 20일 고가를 종가로 돌파했고 상대 거래량은 2.83배였습니다. 직전 20일 평균 거래대금은 약 249억 원이었으며 가격은 상승 중인 120일 SMA 위에 있었습니다.

다음 거래일 시가 44,319원에 진입하고 40거래일 보유 조건이 끝난 다음 시가 69,295원에 청산한 거래수익률은 비용 차감 후 +55.9%였습니다. 거래량 급증뿐 아니라 가격 돌파와 장기 추세가 같은 방향으로 이어진 사례입니다.
이 거래 하나로 필터의 우위를 말할 수는 없습니다. 전체 추세·유동성 규칙의 승률은 40.1%였고, 큰 수익 거래 일부가 여러 작은 손실을 보완했습니다.
실패 사례: 거래량이 19.7배였던 SOOP
2025년 2월 5일 SOOP는 가격 돌파와 함께 상대 거래량이 19.67배까지 뛰었습니다. 거래량 배수만 보면 매우 강한 신호입니다. 그러나 직전 20일 평균 거래대금은 약 78.7억 원으로 정한 유동성 기준보다 낮았고, 장기 상승 추세 조건도 충족하지 못했습니다.

다음 시가 108,954원에 진입했다고 가정하면 돌파는 이어지지 않았습니다. 종가 기준 10% 손절이 발생한 뒤 2월 28일 시가 94,217원에 청산해 거래수익률은 -13.8%였습니다.
거래량이 20배 가까이 증가했다는 사실은 관심이 집중됐다는 뜻입니다. 그 관심이 매수 우위인지, 단기 차익 실현과 매도 물량이 함께 늘어난 것인지는 거래량만으로 알 수 없습니다. 상대 거래량은 방향 지표가 아니라 참여 강도 지표입니다.
실제 주문 전에 더 확인할 것
- 거래량은 주식 수이고 거래대금은 금액입니다. 주가가 다른 종목을 거래량만으로 비교하지 않습니다.
- 신호일 거래대금이 아니라 신호 이전 평균 거래대금으로 평소 유동성을 확인합니다.
- 평균 거래대금 대비 주문 비율과 호가 잔량을 함께 봅니다.
- 상한가·VI·뉴스 급등에서는 다음 시가와 손절 가격이 예상보다 크게 벌어질 수 있습니다.
- 수정주가와 수정 거래량으로 계산한 과거 거래대금은 법인행사 당시 실제 주문 금액을 완벽하게 재현하지 않습니다.
- 거래량 배수와 추세 필터의 임계값을 반복해 최고 수익률만 고르지 않습니다.
RVOL 지표 계산법: 평균 대비 거래량으로 돌파 강도 확인하기에서 상대 거래량의 계산과 한계를 더 자세히 확인할 수 있습니다.
거래량 필터의 역할을 다시 정리하면
이번 결과를 한 문장으로 줄이면, 거래량 급증은 관심의 크기를 보여 주지만 수익 방향과 체결 가능 규모를 대신하지 못합니다.
- 거래량 2배 조건은 승률을 높이지 않고 거래와 MDD를 줄였습니다.
- 거래대금 필터는 수익 신호보다 주문 가능성을 점검하는 조건에 가깝습니다.
- 상승 추세까지 확인하면 낙폭은 더 낮아졌지만 수익 기회도 줄었습니다.
- 상대 거래량이 20배여도 돌파 실패와 다음 시가 손실이 발생할 수 있습니다.
앞선 돌파매매는 언제 실패할까? 돈치안 채널을 규칙으로 바꾸는 법에서도 거래량 필터는 수익률을 개선하지 못했습니다. 필터를 추가할 때는 신호를 강하게 보이게 만드는 이름보다 실제로 줄이려는 위험을 먼저 정해야 합니다.
이 글은 공개 가격 데이터를 이용한 과거 검증 예시입니다. 특정 종목의 매수·매도 추천이나 미래 수익을 의미하지 않으며, 실제 투자 판단과 책임은 투자자 본인에게 있습니다.