신고가를 넘는 종목을 보면 ‘이번에는 추세가 시작되는 것 아닐까’라는 생각이 듭니다. 그런데 같은 고점 돌파도 어떤 날은 긴 상승의 출발점이 되고, 어떤 날은 며칠 뒤 손절로 끝납니다. 돌파매매의 핵심은 고점을 예측하는 데 있지 않습니다. 어떤 돌파를 사고, 되돌리면 언제 물러나며, 그 선택의 대가를 무엇으로 감수할지 정하는 데 있습니다.
돈치안 채널은 이 판단을 숫자로 고정합니다. 직전 20거래일 고가를 종가로 넘으면 다음 거래일 시가에 진입하고, 직전 10거래일 저가를 이탈하면 다음 시가에 청산합니다. 여기에 거래량과 ADX를 하나씩 붙였을 때 무엇이 달라지는지도 같은 조건으로 비교했습니다.
이 글의 핵심 결론
- 기준 돌파 전략은 이 표본에서 매수 후 보유보다 수익률이 낮았지만, MDD는 -18.3%로 매수 후 보유(-37.5%)보다 작았습니다.
- 성공은 긴 추세 한 번에서 나왔습니다. 에코프로 사례에서는 72거래일 보유로 +322.9%를 기록했습니다.
- 실패는 횡보장에서 반복됐습니다. 삼성물산의 한 해에는 5회 진입이 모두 손실로 끝나 합성 -43.1%를 기록했습니다.
- 거래량 필터는 기대와 달리 성과·MDD를 개선하지 못했습니다. ADX 필터는 MDD를 낮췄지만, 수익률과 거래 기회도 함께 줄였습니다.
먼저 고점 돌파를 주문 규칙으로 바꾸기
돈치안 채널 계산법: 상단 돌파는 추세의 시작일까?에서 채널의 계산 원리를 살펴봤다면, 전략에는 체결·청산·비용 규칙을 더해야 합니다. 이번 검증에서는 다음 조건을 고정했습니다.
| 구성 요소 | 기준 규칙 | 이유 |
|---|---|---|
| 진입 채널 | 직전 20거래일 고가 | 당일 고가를 채널에 섞지 않고, 장 마감 뒤 확정한 돌파만 봅니다. |
| 진입 신호 | 종가가 20일 고가를 처음 넘을 때 | 이미 채널 위에 있는 날마다 반복 진입하는 것을 막습니다. |
| 진입 체결 | 신호 다음 거래일 시가 | 종가를 본 뒤 같은 종가에 체결했다고 가정하지 않습니다. |
| 청산 채널 | 직전 10거래일 저가 | 추세가 꺾이면 기계적으로 시장 노출을 줄이는 기준입니다. |
| 손절 | 진입가 대비 종가 -10% 이하일 때 다음 시가 청산 | 채널 이탈을 기다리는 동안 손실이 과도하게 커지는 상황을 제한합니다. |
| 거래비용 | 매수·매도 각각 0.15% | 수수료·세금·슬리피지를 단순화한 가정이며, 과거의 실제 비용을 재현한 값은 아닙니다. |
20일과 10일은 가장 높은 수익률을 찾아 고른 값이 아닙니다. 짧은 돌파와 더 빠른 청산을 비교하기 위해 사전에 정한 출발값입니다. 기간을 조금씩 바꿔 가장 높은 결과만 남기면 전략보다 과거 데이터에 맞춘 조합을 고르게 됩니다.
신호부터 성과표까지 실행하는 코드
아래 코드는 yfinance 수정 일봉으로 30종목을 받고, 신호 확정·다음 거래일 시가 체결·비용 차감·청산·자산 곡선·MDD를 계산합니다. 기준 돌파, 거래량 확인, ADX 추세 확인, 두 필터를 함께 쓴 경우를 같은 조건으로 출력합니다. 차트 생성은 포함하지 않았습니다.
필요한 패키지는 한 번만 설치합니다.
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
ADX_WINDOW_DAYS = 14
ADX_MINIMUM = 25.0
RELATIVE_VOLUME_MINIMUM = 1.2
STOP_LOSS_RATE = 0.10
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["breakout_signal"] = (signal_data["close"] > signal_data["entry_channel"]) & (signal_data["close"].shift(1) <= signal_data["entry_channel"].shift(1))
signal_data["exit_signal"] = (signal_data["close"] < signal_data["exit_channel"]) & (signal_data["close"].shift(1) >= signal_data["exit_channel"].shift(1))
previous_close = signal_data["close"].shift(1)
true_range = pd.concat([(signal_data["high"] - signal_data["low"]), (signal_data["high"] - previous_close).abs(), (signal_data["low"] - previous_close).abs()], axis=1).max(axis=1)
upward_move = signal_data["high"].diff()
downward_move = -signal_data["low"].diff()
positive_directional_move = upward_move.where((upward_move > downward_move) & (upward_move > 0.0), 0.0)
negative_directional_move = downward_move.where((downward_move > upward_move) & (downward_move > 0.0), 0.0)
average_true_range = true_range.ewm(alpha=1.0 / ADX_WINDOW_DAYS, adjust=False, min_periods=ADX_WINDOW_DAYS).mean()
positive_directional_index = 100.0 * positive_directional_move.ewm(alpha=1.0 / ADX_WINDOW_DAYS, adjust=False, min_periods=ADX_WINDOW_DAYS).mean() / average_true_range
negative_directional_index = 100.0 * negative_directional_move.ewm(alpha=1.0 / ADX_WINDOW_DAYS, adjust=False, min_periods=ADX_WINDOW_DAYS).mean() / average_true_range
directional_index = 100.0 * (positive_directional_index - negative_directional_index).abs() / (positive_directional_index + negative_directional_index)
signal_data["adx"] = directional_index.ewm(alpha=1.0 / ADX_WINDOW_DAYS, adjust=False, min_periods=ADX_WINDOW_DAYS).mean()
signal_data["relative_volume"] = signal_data["volume"] / signal_data["volume"].shift(1).rolling(20).mean()
signal_data["baseline_entry"] = signal_data["breakout_signal"]
signal_data["relative_volume_entry"] = signal_data["breakout_signal"] & (signal_data["relative_volume"] >= RELATIVE_VOLUME_MINIMUM)
signal_data["adx_entry"] = signal_data["breakout_signal"] & (signal_data["adx"] >= ADX_MINIMUM)
signal_data["combined_entry"] = signal_data["relative_volume_entry"] & (signal_data["adx"] >= ADX_MINIMUM)
return signal_data.loc[TEST_START:].dropna(subset=["entry_channel", "exit_channel"])
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
capital_before_entry = 0.0
pending_order = ""
trade_returns: list[float] = []
equity_values: list[float] = []
for _, price_row in signal_data.iterrows():
if pending_order == "buy" and position_shares == 0.0:
capital_before_entry = cash
entry_price = float(price_row["open"])
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
if close_return <= -STOP_LOSS_RATE or bool(price_row["exit_signal"]):
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 = {
"누적수익률": 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
strategy_columns = {
"기준 돌파": "baseline_entry",
"거래량 확인": "relative_volume_entry",
"ADX 추세 확인": "adx_entry",
"거래량·ADX 확인": "combined_entry",
}
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 main() -> None:
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)})
print(pd.DataFrame(summary_rows).to_string(index=False, formatters={"누적수익률": "{:+.1%}".format, "MDD": "{:.1%}".format, "승률": "{:.1%}".format}))
if __name__ == "__main__":
main()
shift(1)이 미래 참조를 막는 핵심입니다. 오늘 종가가 직전 20일 고가를 넘었는지 보려면, 채널은 오늘을 제외한 과거 20거래일로 계산해야 합니다. pending_order는 종가 신호를 그날 체결하지 않고 다음 거래일 시가로 넘기는 상태값입니다.
기준 전략은 수익률보다 낙폭에서 무엇을 바꿨을까
로컬에 보관한 30종목의 수정 일봉을 2014년 1월 2일부터 2025년 12월 30일까지 계산했습니다. 각 종목에 같은 비중으로 배분하고 일별 자산을 평균했습니다. 비교 대상은 같은 종목을 시작 시점에 매수해 마지막 거래일까지 보유한 경우입니다.
| 전략 | 누적수익률 | 연환산수익률 | MDD | 거래 수(종목 합산) | 승률 | 10% 손절 |
|---|---|---|---|---|---|---|
| 기준 돌파 | +157.9% | +8.2% | -18.3% | 1,277회 | 39.4% | 144회 |
| 동일 종목 매수 후 보유 | +1,308.1% | +24.7% | -37.5% | – | – | – |

이 결과에서 기준 돌파를 ‘더 좋은 전략’이라고 부를 수는 없습니다. 이 표본에서는 매수 후 보유가 수익률에서 크게 앞섰습니다. 대신 기준 돌파는 10일 저가 이탈 때 시장 밖으로 나와, 고점 대비 낙폭을 약 19.1%포인트 낮췄습니다.
따라서 이 전략의 역할은 모든 상승을 보유하는 일이 아니라, 추세가 꺾였을 때 노출을 줄이는 데 있습니다. 수익률과 MDD를 함께 읽는 기준은 MDD 뜻과 계산법: 낙폭이 작아도 회복이 오래 걸리는 이유에서 더 자세히 다룹니다.
성공 사례: 작은 손실 여러 번을 만회한 한 번의 추세
기준 돌파의 가장 큰 단일 거래는 에코프로에서 나왔습니다. 2023년 1월 27일 진입해 5월 12일 청산했고, 72거래일 동안 +322.9%를 기록했습니다.

이 사례가 보여 주는 것은 ‘돌파가 정확하다’는 말이 아닙니다. 돌파 이후 가격이 조정할 때도 10일 저가를 이탈하지 않는 동안 포지션을 유지했기 때문에, 긴 추세 한 번이 앞선 작은 손실을 만회할 수 있었습니다.
돌파 전략의 승률이 39.4%에 그친 이유도 여기 있습니다. 모든 거래에서 맞히려는 전략이 아니라, 손실 거래를 제한하면서 일부 큰 추세를 끝까지 보유하려는 구조입니다. 그래서 연속 손실이 나왔다고 규칙을 바로 버리면, 정작 이 전략이 보상받는 구간에 참여하지 못할 수 있습니다.
실패 사례: 돌파가 반복되지만 추세가 시작되지 않는 구간
반대로 삼성물산의 2015년에는 기준 돌파가 5번 발생했고, 다섯 거래가 모두 손실로 끝났습니다. 거래별 수익률을 순서대로 합성하면 -43.1%입니다.
| 진입일 | 청산일 | 거래수익률 | 청산 이유 |
|---|---|---|---|
| 2015-02-16 | 2015-03-30 | -1.6% | 10일 저가 이탈 |
| 2015-04-24 | 2015-05-08 | -18.8% | 종가 기준 10% 손절 |
| 2015-05-27 | 2015-06-11 | -7.3% | 10일 저가 이탈 |
| 2015-07-17 | 2015-07-21 | -11.6% | 종가 기준 10% 손절 |
| 2015-09-01 | 2015-09-15 | -13.0% | 종가 기준 10% 손절 |

이 구간에서 문제는 신호 계산 오류가 아닙니다. 가격이 20일 고가를 넘을 때마다 추세가 이어지지 않았고, 다시 10일 저가 아래로 내려왔습니다. 돈치안 돌파는 횡보장과 추세장을 완벽하게 구분하지 못합니다. 이른바 휩쏘는 규칙의 예외가 아니라, 추세추종 전략이 감수해야 하는 비용입니다.
거래량과 ADX를 붙이면 실패가 줄어들까
실패 원인이 다르므로 필터도 한 번에 두 개를 넣지 않고, 아래 세 가설을 분리했습니다.
| 비교 | 가설 | 진입에 추가한 조건 |
|---|---|---|
| 거래량 확인 | 참여가 약한 돌파는 되돌릴 가능성이 높을 수 있다 | 당일 거래량이 직전 20거래일 평균의 1.2배 이상 |
| ADX 추세 확인 | 추세 강도가 낮은 돌파는 횡보장 신호일 수 있다 | ADX(14) 25 이상 |
| 거래량·ADX 확인 | 두 조건을 만족한 돌파만 남기면 휩쏘가 더 줄어들 수 있다 | 두 조건을 모두 충족 |
| 전략 | 누적수익률 | MDD | 거래 수 | 승률 | 기준 전략에서 달라진 점 |
|---|---|---|---|---|---|
| 기준 돌파 | +157.9% | -18.3% | 1,277회 | 39.4% | 비교 기준 |
| 거래량 확인 | +125.7% | -18.4% | 1,152회 | 39.3% | 거래만 125회 줄었고, 수익률·MDD 개선은 없었습니다. |
| ADX 추세 확인 | +57.9% | -11.8% | 632회 | 41.1% | 거래를 절반가량 줄이며 MDD는 낮췄지만, 상승 기회도 많이 놓쳤습니다. |
| 거래량·ADX 확인 | +61.4% | -10.8% | 536회 | 42.5% | 가장 작은 MDD와 가장 높은 승률을 얻었지만, 기준 돌파보다 수익률은 낮았습니다. |

여기서 가장 중요한 결과는 거래량 필터의 실패입니다. 거래량이 평균보다 1.2배 이상인 돌파만 남기면 가짜 돌파가 줄어들 것이라는 가설은 이 표본에서 성립하지 않았습니다. 수익률은 낮아졌고 MDD도 거의 같았습니다.
ADX는 다른 결과를 냈습니다. ADX 25 이상만 통과시키자 MDD는 -18.3%에서 -11.8%로 줄었지만, 누적수익률도 +157.9%에서 +57.9%로 낮아졌습니다. 결합 필터는 MDD를 -10.8%까지 낮췄지만, 거래 수는 1,277회에서 536회로 줄었습니다.
즉 ‘필터를 추가하면 전략이 좋아진다’는 결론은 틀렸습니다. 이 검증에서 ADX는 수익을 높이는 필터가 아니라 거래 빈도와 낙폭을 낮추는 필터였습니다. 어떤 결과를 더 중요하게 볼지는 전략 자체가 아니라 투자자의 위험 한도와 운용 방식에 따라 달라집니다.
필터를 붙이면 무엇을 얻고 무엇을 포기할까
필터의 가치는 신호를 많이 줄이는 데 있지 않습니다. 줄어든 신호만큼 손실과 기회가 어떻게 함께 바뀌는지에 있습니다. 아래 수치는 거래량과 ADX 조건을 붙였을 때 감수해야 할 수익률·낙폭·거래 횟수의 변화를 보여 줍니다.
| 전략 | 누적수익률 | MDD | 거래 수 | 읽는 방법 |
|---|---|---|---|---|
| 기준 돌파 | 약 +155% | 약 -19% | 약 1,280회 | 가장 많은 상승 기회와 더 큰 낙폭을 함께 감수합니다. |
| 거래량 확인 | 약 +124% | 약 -20% | 약 1,160회 | 거래는 줄었지만 낙폭을 줄이지 못했습니다. |
| ADX 추세 확인 | 약 +60% | 약 -12% | 약 630회 | 수익 기회를 줄이는 대신 낙폭을 낮췄습니다. |
| 거래량·ADX 확인 | 약 +59% | 약 -10% | 약 540회 | 가장 작은 낙폭과 가장 적은 거래를 맞바꿨습니다. |
가격 수정 방식에 따라 절대 수치는 조금 달라질 수 있습니다. 다만 이 비교에서 바뀌지 않는 것은 순서입니다. 거래량 조건만으로는 결과가 좋아지지 않았고, ADX 조건은 낙폭을 줄이는 대신 긴 상승 일부를 놓쳤습니다. 필터는 승률을 높이는 장치라기보다, 어느 정도의 손실 폭과 거래 빈도를 감수할지 정하는 선택에 가깝습니다.
이 결과를 일반화하면 안 되는 이유
이번 비교는 전략의 작동 방식과 필터의 대가를 확인하기 위한 기본 검증입니다. 실제 투자 성과로 그대로 해석하면 안 되는 이유가 있습니다.
- 30종목은 현재까지 가격 이력이 충분한 종목으로 구성돼 있어 상장폐지·편입·편출을 모두 반영한 시장 유니버스가 아닙니다. 생존자 편향이 남아 있습니다.
- 수정주가와 편도 0.15% 비용은 단순화된 연구 조건입니다. 실제 매매에서는 호가 단위, 세금 변화, 유동성, 주문 크기에 따라 체결 비용이 달라집니다.
- 종목별로 독립적으로 같은 금액을 운용했다고 가정했습니다. 실제 계좌의 동시 보유 종목 수, 현금 비중, 업종 쏠림, 주문 제한은 반영하지 않았습니다.
- ADX 25와 거래량 1.2배는 가설 검증용 출발값입니다. 다른 임계값을 여러 번 대입해 가장 높은 결과만 고르면 다시 과최적화가 됩니다.
이 한계를 줄이려면 기준일별 종목 유니버스와 상장폐지 종목을 포함하고, 실제 주문 조건에 맞는 수수료·세금·슬리피지를 적용해야 합니다. 백테스트 결과를 믿기 전 확인할 3가지: 미래참조·생존자 편향·과최적화도 함께 확인하는 편이 좋습니다.
이 전략에서 얻을 수 있는 판단 기준
이번 결과를 한 문장으로 줄이면, 돌파 전략은 수익률을 약속하는 공식이 아니라 상승 참여와 낙폭 억제 사이의 비중을 선택하는 규칙입니다.
- 큰 추세를 놓치고 싶지 않다면 기준 돌파처럼 더 많은 신호와 낙폭을 감수해야 합니다.
- 손실 폭과 거래 횟수를 줄이고 싶다면 ADX 필터가 한 후보가 될 수 있지만, 수익 기회 축소를 받아들여야 합니다.
- 거래량 필터처럼 그럴듯한 조건도 실제 표본에서는 효과가 없을 수 있으므로, 추가 전에 반드시 기준 전략과 같은 조건으로 비교해야 합니다.
포지션 크기는 이 선택과 별개로 계좌 위험에서 정해야 합니다. 포지션 사이징 계산법: 1% 룰을 적용하기 전에 알아야 할 것에서 손절 거리와 위험 예산을 함께 확인할 수 있습니다.
이 글은 공개 가격 데이터를 이용한 과거 검증 예시입니다. 특정 종목의 매수·매도 추천이나 미래 수익을 의미하지 않으며, 실제 투자 판단과 책임은 투자자 본인에게 있습니다.