어제보다 큰 가격 움직임이 시작되면 따라붙고, 다음 날 아침에 정리하는 변동성 돌파 전략은 규칙이 단순합니다. 하지만 일봉으로 백테스트할 때는 한 줄의 체결 가정이 결과를 완전히 바꿉니다.
오늘 고가가 돌파 가격을 넘었다는 사실은 장이 진행된 뒤에 알 수 있습니다. 그런데 이 신호를 확인하고도 오늘 시가에 산 것으로 계산하면, 돌파가 일어날 날을 미리 알고 가장 싼 시점으로 돌아간 결과가 됩니다. 목표가보다 높게 시작한 날에도 원래 목표가에 체결됐다고 처리하면 실제로 살 수 없었던 가격이 남습니다.
이번에는 같은 돌파 신호를 네 가지 체결 방식으로 나눴습니다. 당일 시가로 소급한 결과, 돌파 가격에 항상 체결됐다고 본 결과, 갭을 실제 시초가에 반영한 결과, 일봉 신호를 종가에 확인하고 다음 시가에 진입한 결과를 비교했습니다.
이 글의 핵심 결론
- 당일 고가로 신호를 확인한 뒤 당일 시가에 산 결과는 약 39억 배의 비현실적인 자산 곡선을 만들었습니다.
- 돌파 가격에 항상 체결됐다고 보면 누적수익률은 +2,924.1%였지만, 시초가가 목표가를 넘은 날의 실제 가격을 반영하자 +152.7%로 줄었습니다.
- 전체 신호의 20.0%가 목표가보다 높은 시초가로 시작했습니다. 이 구간의 평균 거래수익률은 고정 목표가 체결에서 +0.67%, 갭 반영 체결에서 -0.17%로 뒤집혔습니다.
- 종가 확인 뒤 다음 시가에 진입한 별도 규칙은 -72.8%였습니다. 신호가 같아도 언제 살 수 있는지를 바꾸면 다른 전략이 됩니다.
먼저 변동성 돌파 가격을 정하기
이번 비교에서는 전날 종가에 전날 고가와 저가 차이의 절반을 더했습니다.
돌파 가격 = 전일 종가 + 0.5 × (전일 고가 - 전일 저가)
전일 가격만 사용하므로 돌파 가격은 오늘 장이 열리기 전에 계산할 수 있습니다. 오늘 고가가 이 가격 이상이면 돌파 신호가 발생한 것으로 봅니다. 진입 뒤에는 다음 거래일 시가에 전량 청산하고, 매수와 매도에 각각 0.15% 비용을 적용했습니다.
0.5는 가장 높은 수익률을 찾아 고른 값이 아닙니다. 체결 가정만 바꾸기 위해 고정한 출발값입니다. 이 값까지 여러 번 바꾸면 체결 오류와 변수 최적화가 섞여 무엇이 결과를 바꿨는지 알기 어려워집니다.
네 가지 체결 가정은 무엇이 다를까
| 체결 가정 | 진입 가격 | 알 수 있는 시점 | 문제와 의미 |
|---|---|---|---|
| 당일 시가 소급 체결 | 신호일 시가 | 신호는 장중 고가가 돌파한 뒤 확정 | 미래의 고가를 확인하고 과거 시가로 돌아가는 미래 참조입니다. |
| 돌파가 고정 체결 | 계산한 돌파 가격 | 장 시작 전 주문 가능 | 시가가 목표가보다 높아도 목표가에 샀다고 처리하면 유리한 체결이 됩니다. |
| 갭 반영 돌파 체결 | max(돌파 가격, 당일 시가) |
장중 정지 주문을 가정 | 목표가 위로 갭이 뜨면 시가에 체결된 것으로 처리합니다. |
| 종가 확인 후 다음 시가 | 신호 다음 거래일 시가 | 일봉이 끝난 뒤 신호 확인 | 실행 가능하지만 진입이 하루 늦어진 별도의 전략입니다. |
시초가가 돌파 가격보다 낮으면 돌파 정지 주문은 목표가 부근에서 체결될 수 있습니다. 반대로 시초가가 목표가 위에서 시작하면 목표가 주문은 이미 통과된 상태입니다. 이때는 목표가가 아니라 시초가 또는 그보다 불리한 가격을 적용해야 합니다.
일봉에는 장중 주문 대기열과 슬리피지가 없습니다. 따라서 max(돌파 가격, 시가)도 완벽한 체결 재현이 아니라, 목표가 고정 체결보다 보수적인 근사치입니다.
신호와 체결을 분리해서 계산하는 코드
아래 코드는 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"
BREAKOUT_MULTIPLIER = 0.5
ONE_WAY_COST_RATE = 0.0015
def add_breakout_signal(price_data: pd.DataFrame) -> pd.DataFrame:
signal_data = price_data.copy()
signal_data["previous_close"] = signal_data["close"].shift(1)
signal_data["previous_range"] = signal_data["high"].shift(1) - signal_data["low"].shift(1)
signal_data["breakout_price"] = signal_data["previous_close"] + BREAKOUT_MULTIPLIER * signal_data["previous_range"]
signal_data["breakout_signal"] = signal_data["high"] >= signal_data["breakout_price"]
signal_data["gap_above_breakout"] = signal_data["open"] > signal_data["breakout_price"]
return signal_data.loc[TEST_START:TEST_END].dropna(subset=["breakout_price"])
def build_trade_table(signal_data: pd.DataFrame, execution_rule: str) -> pd.DataFrame:
trade_data = signal_data.loc[signal_data["breakout_signal"]].copy()
if execution_rule == "retrospective_open":
trade_data["entry_price"] = trade_data["open"]
trade_data["exit_price"] = signal_data["open"].shift(-1).reindex(trade_data.index)
exit_dates = pd.Series(signal_data.index, index=signal_data.index).shift(-1)
elif execution_rule == "fixed_breakout":
trade_data["entry_price"] = trade_data["breakout_price"]
trade_data["exit_price"] = signal_data["open"].shift(-1).reindex(trade_data.index)
exit_dates = pd.Series(signal_data.index, index=signal_data.index).shift(-1)
elif execution_rule == "gap_aware_breakout":
trade_data["entry_price"] = trade_data[["open", "breakout_price"]].max(axis=1)
trade_data["exit_price"] = signal_data["open"].shift(-1).reindex(trade_data.index)
exit_dates = pd.Series(signal_data.index, index=signal_data.index).shift(-1)
elif execution_rule == "next_open_confirmation":
trade_data["entry_price"] = signal_data["open"].shift(-1).reindex(trade_data.index)
trade_data["exit_price"] = signal_data["open"].shift(-2).reindex(trade_data.index)
exit_dates = pd.Series(signal_data.index, index=signal_data.index).shift(-2)
else:
raise ValueError(f"알 수 없는 체결 규칙입니다: {execution_rule}")
trade_data["exit_date"] = exit_dates.reindex(trade_data.index).values
trade_data = trade_data.dropna(subset=["entry_price", "exit_price", "exit_date"])
trade_data["trade_return"] = trade_data["exit_price"] / trade_data["entry_price"] * (1.0 - ONE_WAY_COST_RATE) ** 2 - 1.0
return trade_data
def build_equity_curve(signal_data: pd.DataFrame, trade_data: pd.DataFrame) -> pd.Series:
daily_multiplier = pd.Series(1.0, index=signal_data.index)
return_by_exit_date = trade_data.groupby("exit_date")["trade_return"].apply(lambda values: (1.0 + values).prod())
valid_exit_dates = pd.DatetimeIndex(return_by_exit_date.index).intersection(daily_multiplier.index)
daily_multiplier.loc[valid_exit_dates] = return_by_exit_date.reindex(valid_exit_dates).values
return daily_multiplier.cumprod()
def summarize_strategy(equity_curves: list[pd.Series], trade_tables: list[pd.DataFrame]) -> 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
all_trades = pd.concat(trade_tables, ignore_index=True)
return {
"누적수익률": float(portfolio_equity.iloc[-1] - 1.0),
"MDD": float(drawdown_curve.min()),
"거래 수": int(len(all_trades)),
"승률": float((all_trades["trade_return"] > 0.0).mean()),
"평균 거래수익률": float(all_trades["trade_return"].mean()),
}
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() -> tuple[pd.DataFrame, pd.DataFrame]:
execution_rules = {
"당일 시가 소급 체결": "retrospective_open",
"돌파가 고정 체결": "fixed_breakout",
"갭 반영 돌파 체결": "gap_aware_breakout",
"종가 확인 후 다음 시가": "next_open_confirmation",
}
equity_by_rule = {rule_name: [] for rule_name in execution_rules}
trades_by_rule = {rule_name: [] for rule_name in execution_rules}
for ticker in TICKERS:
signal_data = add_breakout_signal(download_price_data(ticker))
for rule_name, execution_rule in execution_rules.items():
trade_data = build_trade_table(signal_data, execution_rule)
equity_by_rule[rule_name].append(build_equity_curve(signal_data, trade_data))
trades_by_rule[rule_name].append(trade_data)
summary_rows = [
{"체결 가정": rule_name, **summarize_strategy(equity_by_rule[rule_name], trades_by_rule[rule_name])}
for rule_name in execution_rules
]
fixed_breakout_trades = pd.concat(trades_by_rule["돌파가 고정 체결"], ignore_index=True)
gap_aware_trades = pd.concat(trades_by_rule["갭 반영 돌파 체결"], ignore_index=True)
gap_mask = gap_aware_trades["gap_above_breakout"]
gap_summary = pd.DataFrame(
[
{
"전체 신호": len(gap_aware_trades),
"목표가 위 갭": int(gap_mask.sum()),
"갭 비율": float(gap_mask.mean()),
"고정 체결 갭 평균": float(fixed_breakout_trades.loc[gap_mask, "trade_return"].mean()),
"갭 반영 평균": float(gap_aware_trades.loc[gap_mask, "trade_return"].mean()),
}
]
)
return pd.DataFrame(summary_rows), gap_summary
def main() -> None:
summary, gap_summary = build_summary()
print(
summary.to_string(
index=False,
formatters={
"누적수익률": "{:+.1%}".format,
"MDD": "{:.1%}".format,
"승률": "{:.1%}".format,
"평균 거래수익률": "{:+.3%}".format,
},
)
)
print(
gap_summary.to_string(
index=False,
formatters={
"갭 비율": "{:.1%}".format,
"고정 체결 갭 평균": "{:+.3%}".format,
"갭 반영 평균": "{:+.3%}".format,
},
)
)
if __name__ == "__main__":
main()
previous_close와 previous_range에 사용한 shift(1)은 돌파 가격을 장 시작 전에 확정합니다. retrospective_open은 일부러 잘못된 체결을 재현해 미래 참조가 결과를 얼마나 부풀리는지 보여 줍니다. 실제 규칙으로 사용해서는 안 됩니다.
당일 시가 소급 체결은 전략이 아니라 오류였다
2014년 1월 2일부터 2025년 12월 30일까지 수정 일봉 30종목을 같은 비중으로 계산했습니다. 종목마다 돌파 신호가 발생하면 한 단위 자산을 진입하고, 다음 시가에 청산했다고 가정했습니다.
| 체결 가정 | 누적수익률 | MDD | 거래 수 | 승률 | 평균 거래수익률 |
|---|---|---|---|---|---|
| 당일 시가 소급 체결 | +394,705,987,794.5% | -14.8% | 38,081회 | 62.9% | +0.960% |
| 돌파가 고정 체결 | +2,924.1% | -24.7% | 38,081회 | 45.7% | +0.107% |
| 갭 반영 돌파 체결 | +152.7% | -29.0% | 38,081회 | 43.4% | -0.060% |
| 종가 확인 후 다음 시가 | -72.8% | -80.3% | 38,061회 | 42.6% | -0.187% |

당일 시가 소급 결과는 누적수익률이 약 3,947억%에 달합니다. 좋은 전략을 찾은 것이 아닙니다. 오늘 고가가 돌파할 날에만 오늘 시가를 골라 샀기 때문에 발생한 미래 참조입니다. 시가 이후 오를 날을 미리 선택했으니 승률과 평균 수익률이 높아지는 것이 당연합니다.
돌파가 고정 체결도 +2,924.1%로 좋아 보입니다. 그러나 이 계산에는 시초가가 이미 목표가를 넘어섰는데도 더 낮은 목표가에 산 거래가 포함돼 있습니다. 갭을 반영하자 누적수익률은 +152.7%로 줄고 MDD는 -29.0%로 커졌습니다.
종가 확인 뒤 다음 시가에 진입한 결과가 -72.8%라고 해서 변동성 돌파 자체가 틀렸다고 결론 내릴 수는 없습니다. 장중 돌파 주문과 하루 늦은 시가 진입은 보유 구간이 다릅니다. 이 비교가 보여 주는 것은 신호 이름이 같아도 체결 시점을 바꾸면 같은 전략이 아니라는 사실입니다.
전체 신호의 20%에서 갭 체결이 발생했다
38,081개 신호 중 7,609개는 시초가가 계산한 돌파 가격보다 높았습니다. 약 다섯 번 중 한 번입니다.
| 구간 | 돌파가 고정 체결 평균 | 갭 반영 체결 평균 | 차이 |
|---|---|---|---|
| 목표가 위 갭 출발 | +0.668% | -0.168% | -0.836%p |
| 갭 없음 | 약 -0.03% | 약 -0.03% | 거의 없음 |

갭이 없을 때는 두 방식의 진입 가격이 같으므로 결과도 같습니다. 차이는 목표가 위에서 시작한 날에 집중됐습니다. 목표가에 체결됐다고 보면 평균 +0.668%였지만, 실제 시초가를 적용하면 -0.168%가 됐습니다.
이 차이는 슬리피지를 추가해서 생긴 것이 아닙니다. 살 수 없었던 낮은 가격을 실제로 주문 가능한 시초가로 바꾼 결과입니다. 여기에 호가 공백과 주문 지연까지 반영하면 체결은 더 불리해질 수 있습니다.
성공 사례: 목표가를 장중에 통과한 LG전자
2020년 12월 23일 LG전자의 전일 종가는 86,899원이었고 전일 고가·저가 범위는 2,733원이었습니다. 계산한 돌파 가격은 88,266원입니다. 당일 시가는 87,370원으로 목표가보다 낮았고, 장중 고가가 112,630원까지 올라 돌파 주문이 실행될 수 있는 구조였습니다.

돌파 가격 88,266원에 진입하고 다음 거래일 시가 117,813원에 청산한 거래수익률은 비용 차감 후 +33.1%였습니다. 이 사례에서는 시초가가 목표가 아래에 있었으므로 고정 돌파가와 갭 반영 체결 가격이 같습니다.
이 결과가 모든 변동성 돌파의 성공을 의미하지는 않습니다. 하루 동안 큰 가격 움직임이 이어지고 다음 날까지 갭이 유지된 드문 사례입니다. 전체 갭 반영 거래의 승률이 43.4%였다는 점을 함께 봐야 합니다.
실패 사례: 목표가보다 8.3% 높게 시작한 펄어비스
2024년 6월 7일 펄어비스의 돌파 가격은 41,725원이었습니다. 하지만 시초가는 이미 45,200원으로 목표가보다 8.3% 높았습니다. 장중 고가는 45,400원에 그쳤고 다음 거래일 시가는 39,300원으로 내려왔습니다.

목표가 41,725원에 체결됐다고 계산하면 거래수익률은 -6.1%입니다. 실제 시초가 45,200원을 적용하면 손실은 -13.3%로 커집니다. 같은 신호와 같은 청산 가격인데 진입가 하나로 손실이 두 배 넘게 달라졌습니다.
이 거래에서 당일 시가 소급 체결과 갭 반영 체결은 모두 45,200원입니다. 시초가가 목표가보다 높은 날에는 시가가 오히려 실행 가능한 첫 가격이기 때문입니다. 미래 참조의 문제는 시가가 목표가보다 낮은 날, 장중 돌파가 발생할 것을 미리 알고 낮은 시가에 진입하는 거래에서 반복됩니다.
일봉만으로 확인할 수 없는 것
갭을 반영해도 일봉 백테스트에는 여전히 빈칸이 남습니다.
- 고가가 목표가를 한 번 찍었을 뿐인지, 충분한 거래량으로 주문이 체결됐는지 알 수 없습니다.
- 상한가·VI·급격한 호가 공백에서는 목표가 또는 시초가 체결도 보장되지 않습니다.
- 여러 종목이 동시에 돌파하면 계좌 현금과 주문 우선순위 때문에 모든 신호를 살 수 없습니다.
- 다음 시가 청산도 하한가, 거래정지, 유동성 부족에서는 예상 가격에 체결되지 않을 수 있습니다.
- 수정주가는 법인행사 수익률을 비교하기에는 유용하지만 당시 실제 호가 단위와 주문 가격을 그대로 재현하지 않습니다.
실제 운용에 가까워지려면 분봉 또는 체결 데이터로 목표가 도달 시각, 도달 뒤 거래량, 호가 간격을 확인해야 합니다. 일봉만 사용할 때는 적어도 시가가 목표가를 넘은 경우를 별도로 분리하고 더 불리한 체결을 적용해야 합니다.
OHLC 변동성 계산법: 종가만 보면 장중 변동을 놓칠까?에서 일봉 고가·저가가 담는 정보와 순서를 알 수 없는 한계를 더 자세히 확인할 수 있습니다.
체결 가정을 점검하는 순서
변동성 돌파 규칙을 검증할 때는 수익률보다 아래 순서를 먼저 확인하는 편이 좋습니다.
- 돌파 가격이 신호 전에 알 수 있는 값으로만 계산됐는지 확인합니다.
- 장중 고가로 확정한 신호를 당일 시가에 소급하지 않습니다.
- 시초가가 목표가를 넘으면
max(목표가, 시가)처럼 갭을 반영합니다. - 목표가 접촉과 실제 체결을 같은 사건으로 단정하지 않습니다.
- 거래비용과 슬리피지를 넣고 누적수익률·MDD·거래 수를 다시 계산합니다.
- 일봉 확인 뒤 다음 시가 진입은 장중 돌파와 다른 전략으로 분리합니다.
백테스트 결과를 믿기 전 확인할 3가지: 미래참조·생존자 편향·과최적화에서 미래 참조를 포함한 기본 점검 기준을 이어서 볼 수 있습니다.
같은 신호보다 살 수 있는 가격이 먼저다
이번 결과의 핵심은 변동성 돌파가 수익을 내는지 여부가 아닙니다. 주문할 수 없었던 가격으로 계산한 성과는 전략의 증거가 될 수 없다는 점입니다.
- 당일 고가를 본 뒤 당일 시가로 돌아가면 미래 참조가 됩니다.
- 목표가 위로 갭이 뜨면 목표가가 아니라 시초가 또는 더 불리한 가격을 적용해야 합니다.
- 일봉 종가 확인 뒤 다음 시가에 사면 실행은 가능하지만 보유 구간이 바뀝니다.
- 체결 규칙을 고친 뒤에도 일봉은 주문 대기열과 슬리피지를 보여 주지 못합니다.
앞선 돌파매매는 언제 실패할까? 돈치안 채널을 규칙으로 바꾸는 법이 어떤 돌파를 살지 다뤘다면, 이번 글은 그 신호를 실제로 언제 살 수 있는지 구분했습니다. 신호 규칙과 체결 규칙이 함께 있어야 하나의 전략이 완성됩니다.
이 글은 공개 가격 데이터를 이용한 과거 검증 예시입니다. 특정 종목의 매수·매도 추천이나 미래 수익을 의미하지 않으며, 실제 투자 판단과 책임은 투자자 본인에게 있습니다.