종가가 20일 이동평균선을 돌파한 순간을 보고 매수했다고 가정해 보겠습니다. 차트에는 신호가 발생한 종가가 이미 표시되어 있으니 그 가격에 매수한 것으로 계산하기 쉽습니다.
문제는 그 종가를 장이 끝나기 전에 알 수 없다는 점입니다. 종가와 당일 거래량이 확정돼야 신호를 계산할 수 있는데, 이미 확정된 종가로 신호를 만든 뒤 같은 가격에 체결됐다고 기록하면 시간의 순서가 뒤집힙니다.
이전 글인 감으로 찾던 눌림목, 파이썬 자동매매 조건식으로 바꾸는 법에서는 삼성전자 일봉 1,960거래일을 19개의 진입 후보로 줄였습니다. 이번에는 그 후보를 실제 주문으로 바꾸고, 당일 종가 체결과 다음 거래일 시가 체결의 차이를 확인합니다.
이번 글의 핵심 결론
- 종가와 당일 거래량으로 만든 신호는 장 마감 후에야 확정됩니다.
- 같은 종가에 체결했다고 계산한 결과는 실제 주문으로 재현할 수 없습니다.
- 미보유·주문 대기·보유 상태를 구분해야 중복 진입과 시간 순서 오류를 막을 수 있습니다.
1. 신호와 주문은 같은 사건이 아니다
첫 글의 entry_signal은 다음 세 조건이 모두 참인 거래일입니다.
- 60일선이 120일선보다 높고 종가가 120일선보다 높음
- 전일에는 20일선 이하였던 종가가 당일 20일선 위로 올라옴
- 당일 거래량이 직전 20거래일 평균의 1.2배 이상
세 번째 조건 때문에 신호는 장중에 완전히 확정할 수 없습니다. 마감 직전까지 거래량이 변하고 종가도 계속 움직이기 때문입니다.
실제 가능한 순서는 다음과 같습니다.
t일 장 마감
→ 종가와 거래량 확정
→ 진입 또는 청산 신호 계산
→ 주문 대기 상태로 전환
→ t+1일 시가에 주문 체결
이번 글에서는 일부러 잘못된 방식도 함께 계산합니다.
| 구분 | 신호 계산 | 체결 가격 | 실제 재현 가능성 |
|---|---|---|---|
| 당일 종가 체결 | t일 종가·거래량 | t일 종가 | 불가능 |
| 다음 날 시가 체결 | t일 종가·거래량 | t+1일 시가 | 가능 |
두 방식 모두 같은 진입·청산 조건과 같은 비용을 사용합니다. 차이는 체결 시점 하나뿐입니다.
2. 포지션 상태를 정하지 않으면 신호가 주문으로 쌓인다
첫 글에서는 조건을 만족한 19개 거래일을 모두 후보로 표시했습니다. 백테스트에서는 후보가 나올 때마다 매수하면 안 됩니다. 이미 주식을 보유하고 있다면 같은 신호는 새로운 주문이 아니라 무시해야 할 정보일 수 있습니다.
이를 위해 계좌 상태를 다음처럼 나눕니다.
미보유
→ 진입 신호
→ 매수 주문 대기
→ 다음 거래일 시가 체결
→ 보유
→ 청산 신호
→ 매도 주문 대기
→ 다음 거래일 시가 체결
→ 미보유
이 구조에서는 shares == 0일 때만 진입 신호를 받고, shares > 0일 때만 청산 신호를 처리합니다. 보유 중 발생한 추가 진입 신호는 주문으로 만들지 않습니다.
3. 비교 조건을 먼저 고정하기
체결 시점 외의 차이가 결과에 섞이지 않도록 조건을 다음처럼 고정했습니다.
| 항목 | 설정 |
|---|---|
| 대상 | 삼성전자 005930.KS |
| 데이터 | yfinance 수정 일봉 |
| 요청 기간 | 2018-01-01 이상, 2026-01-01 미만 |
| 진입 신호 | 첫 글의 상승 추세·20일선 회복·거래량 조건 |
| 청산 신호 | 종가가 20일선을 하향 이탈한 첫 거래일 |
| 투자 비중 | 미보유 현금 전액 |
| 진입 제한 | 미보유 상태에서만 가능 |
| 재진입 | 청산 후 새로운 진입 신호가 나올 때 가능 |
| 매수·매도 비용 | 각각 0.05%의 예시 비용 |
| 마지막 미청산 포지션 | 검증 구간 마지막 종가로 강제 청산 |
0.05%는 특정 증권사의 실제 수수료나 세금을 의미하지 않습니다. 체결 방식만 비교하기 위해 양쪽에 동일하게 적용한 예시 마찰 비용입니다. 자신의 계좌 조건에 맞는 비용은 수수료·세금·슬리피지를 반영한 파이썬 백테스트를 참고해 교체해야 합니다.
청산은 20일선 하향 이탈 하나만 사용합니다. 이 규칙이 가장 좋은 청산법이라는 뜻이 아니라, 체결 시점의 영향을 분리해 보기 위한 단순한 출구입니다.
4. 전체 Python 코드
필요한 패키지를 설치하지 않았다면 터미널에서 다음 명령을 먼저 실행합니다.
python -m pip install yfinance pandas matplotlib
아래 Python 블록에는 데이터 수집, 신호 계산, 상태 관리, 거래 기록, 성과 계산과 차트 저장이 모두 들어 있습니다.
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
import yfinance as yf
from matplotlib import font_manager
TICKER = "005930.KS"
START_DATE = "2018-01-01"
END_DATE = "2026-01-01"
VOLUME_MULTIPLIER = 1.2
COST_RATE = 0.0005
CHART_FILE = Path("execution_chart.png")
RESULT_FILE = Path("execution_results.csv")
def run_backtest(data: pd.DataFrame, execution_mode: str) -> tuple[pd.DataFrame, pd.DataFrame]:
if execution_mode not in {"same_close", "next_open"}:
raise ValueError(f"지원하지 않는 체결 방식입니다: {execution_mode}")
cash = 1.0
shares = 0.0
pending_order = None
current_trade = None
trades = []
equity_records = []
dates = list(data.index)
for index, (date, row) in enumerate(data.iterrows()):
if pending_order is not None:
if pending_order["side"] == "buy" and shares == 0.0:
capital_before = cash
entry_price = float(row["open"])
shares = cash * (1.0 - COST_RATE) / entry_price
cash = 0.0
current_trade = {"entry_signal_date": pending_order["signal_date"], "entry_date": date, "entry_price": entry_price, "capital_before": capital_before}
elif pending_order["side"] == "sell" and shares > 0.0:
exit_price = float(row["open"])
cash = shares * exit_price * (1.0 - COST_RATE)
shares = 0.0
trades.append({**current_trade, "exit_signal_date": pending_order["signal_date"], "exit_date": date, "exit_price": exit_price, "trade_return": cash / current_trade["capital_before"] - 1.0, "forced_exit": False})
current_trade = None
pending_order = None
if shares > 0.0 and bool(row["exit_signal"]):
if execution_mode == "same_close":
exit_price = float(row["close"])
cash = shares * exit_price * (1.0 - COST_RATE)
shares = 0.0
trades.append({**current_trade, "exit_signal_date": date, "exit_date": date, "exit_price": exit_price, "trade_return": cash / current_trade["capital_before"] - 1.0, "forced_exit": False})
current_trade = None
elif index + 1 < len(data):
pending_order = {"side": "sell", "signal_date": date}
elif shares == 0.0 and bool(row["entry_signal"]):
if execution_mode == "same_close":
capital_before = cash
entry_price = float(row["close"])
shares = cash * (1.0 - COST_RATE) / entry_price
cash = 0.0
current_trade = {"entry_signal_date": date, "entry_date": date, "entry_price": entry_price, "capital_before": capital_before}
elif index + 1 < len(data):
pending_order = {"side": "buy", "signal_date": date}
equity_records.append({"date": date, "equity": cash + shares * float(row["close"])})
if shares > 0.0:
last_date = dates[-1]
last_close = float(data.iloc[-1]["close"])
cash = shares * last_close * (1.0 - COST_RATE)
trades.append({**current_trade, "exit_signal_date": last_date, "exit_date": last_date, "exit_price": last_close, "trade_return": cash / current_trade["capital_before"] - 1.0, "forced_exit": True})
equity_records[-1]["equity"] = cash
trade_frame = pd.DataFrame(trades)
equity_frame = pd.DataFrame(equity_records).set_index("date")
return trade_frame, equity_frame
def summarize(trades: pd.DataFrame, equity: pd.DataFrame) -> dict[str, float]:
elapsed_years = (equity.index[-1] - equity.index[0]).days / 365.25
total_return = float(equity["equity"].iloc[-1] - 1.0)
cagr = float(equity["equity"].iloc[-1] ** (1.0 / elapsed_years) - 1.0)
drawdown = equity["equity"] / equity["equity"].cummax() - 1.0
return {"누적수익률": total_return, "CAGR": cagr, "MDD": float(drawdown.min()), "승률": float((trades["trade_return"] > 0.0).mean()), "거래수": float(len(trades))}
available_fonts = {item.name for item in font_manager.fontManager.ttflist}
for font_name in ("Malgun Gothic", "AppleGothic", "UnDotum", "DejaVu Sans"):
if font_name in available_fonts:
plt.rcParams["font.family"] = font_name
break
plt.rcParams["axes.unicode_minus"] = False
price = yf.download(TICKER, start=START_DATE, end=END_DATE, auto_adjust=True, progress=False, multi_level_index=False)
if price.empty:
raise RuntimeError(f"가격 데이터를 받지 못했습니다: {TICKER}")
price = price.rename(columns=str.lower).dropna().copy()
price["sma_20"] = price["close"].rolling(20).mean()
price["sma_60"] = price["close"].rolling(60).mean()
price["sma_120"] = price["close"].rolling(120).mean()
price["volume_average_20"] = price["volume"].shift(1).rolling(20).mean()
price["entry_signal"] = (price["sma_60"] > price["sma_120"]) & (price["close"] > price["sma_120"]) & (price["close"] > price["sma_20"]) & (price["close"].shift(1) <= price["sma_20"].shift(1)) & (price["volume"] >= price["volume_average_20"] * VOLUME_MULTIPLIER)
price["exit_signal"] = (price["close"] < price["sma_20"]) & (price["close"].shift(1) >= price["sma_20"].shift(1))
same_close_trades, same_close_equity = run_backtest(price, "same_close")
next_open_trades, next_open_equity = run_backtest(price, "next_open")
same_close_summary = summarize(same_close_trades, same_close_equity)
next_open_summary = summarize(next_open_trades, next_open_equity)
summary = pd.DataFrame([same_close_summary, next_open_summary], index=["당일 종가 체결", "다음 날 시가 체결"])
summary["거래수"] = summary["거래수"].astype(int)
trade_comparison = same_close_trades[["entry_signal_date", "trade_return"]].rename(columns={"trade_return": "당일_종가_거래수익률"}).merge(next_open_trades[["entry_signal_date", "entry_date", "entry_price", "trade_return"]].rename(columns={"trade_return": "다음날_시가_거래수익률"}), on="entry_signal_date", how="inner")
trade_comparison["수익률차이"] = trade_comparison["당일_종가_거래수익률"] - trade_comparison["다음날_시가_거래수익률"]
trade_comparison.to_csv(RESULT_FILE, index=False, encoding="utf-8-sig")
print(summary.to_string(formatters={"누적수익률": "{:.2%}".format, "CAGR": "{:.2%}".format, "MDD": "{:.2%}".format, "승률": "{:.2%}".format}))
print(f"누적수익률 차이: {same_close_summary['누적수익률'] - next_open_summary['누적수익률']:.2%}p")
print(trade_comparison.tail(10).to_string(index=False))
fig, (equity_axis, difference_axis) = plt.subplots(2, 1, figsize=(12, 8), gridspec_kw={"height_ratios": [3, 1.4]}, facecolor="white")
equity_axis.plot(same_close_equity.index, same_close_equity["equity"], color="#F59E0B", linewidth=2.0, label=f"당일 종가 체결 {same_close_summary['누적수익률']:.1%}")
equity_axis.plot(next_open_equity.index, next_open_equity["equity"], color="#0F766E", linewidth=2.0, label=f"다음 날 시가 체결 {next_open_summary['누적수익률']:.1%}")
equity_axis.set_title("같은 종가 신호, 다른 체결 시점", fontsize=17, fontweight="bold", pad=16)
equity_axis.set_ylabel("누적 자산")
equity_axis.legend(loc="upper left", frameon=False)
equity_axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
equity_axis.yaxis.tick_right()
equity_axis.yaxis.set_label_position("right")
bar_colors = ["#F59E0B" if value >= 0.0 else "#0F766E" for value in trade_comparison["수익률차이"]]
difference_axis.bar(trade_comparison["entry_signal_date"], trade_comparison["수익률차이"] * 100.0, color=bar_colors, width=20.0)
difference_axis.axhline(0.0, color="#64748B", linewidth=1.0)
difference_axis.text(0.01, 0.92, "양수: 당일 종가 체결 수익률이 높음", transform=difference_axis.transAxes, color="#64748B", fontsize=10, va="top")
difference_axis.set_ylabel("종가-시가(%p)")
difference_axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
difference_axis.yaxis.tick_right()
difference_axis.yaxis.set_label_position("right")
for axis in (equity_axis, difference_axis):
axis.spines["top"].set_visible(False)
axis.spines["left"].set_visible(False)
axis.spines["right"].set_color("#D1D5DB")
axis.spines["bottom"].set_color("#D1D5DB")
fig.tight_layout()
fig.savefig(CHART_FILE, dpi=160, bbox_inches="tight", facecolor="white")
plt.show()
코드의 핵심은 pending_order입니다. 종가에서 신호가 나오면 즉시 주식을 사는 대신 매수 또는 매도 주문을 대기 상태로 저장합니다. 반복문이 다음 거래일로 넘어왔을 때 시가로 주문을 실행합니다.
current_trade에는 신호일과 체결일을 따로 기록합니다. 거래 내역에서 두 날짜가 같다면 당일 종가 체결이고, 하루 이상 차이가 나면 다음 거래일 시가 체결입니다. 공휴일과 주말이 있어 달력 날짜로는 며칠 차이가 날 수 있습니다.
5. 같은 신호인데 누적수익률이 11.8%p 달라졌다
작성 환경에서 실행한 결과입니다.
| 체결 방식 | 누적수익률 | CAGR | MDD | 승률 | 거래 수 |
|---|---|---|---|---|---|
| 당일 종가 체결 | 82.87% | 7.85% | -15.12% | 36.84% | 19회 |
| 다음 날 시가 체결 | 71.10% | 6.95% | -16.31% | 42.11% | 19회 |
당일 종가 체결의 누적수익률은 82.87%, 다음 거래일 시가 체결은 71.10%였습니다. 체결 시점 하나만 바꿨는데 최종 차이는 11.77%p였습니다.

상단 자산곡선은 두 방식이 같은 신호를 사용해도 시간이 지날수록 간격이 벌어지는 모습을 보여줍니다. 작은 체결가 차이가 각 거래의 원금에 반영되고, 이후 거래에 다시 복리로 이어졌기 때문입니다.
하단 막대는 각 거래에서 당일 종가 체결 수익률 - 다음 날 시가 체결 수익률을 계산한 값입니다. 주황색은 당일 종가 체결이 유리했던 거래, 초록색은 다음 날 시가 체결이 유리했던 거래입니다.
19건 중 당일 종가 체결이 유리한 거래는 11건, 다음 날 시가 체결이 유리한 거래는 8건이었습니다. 다음 날 시가가 항상 불리한 것은 아닙니다. 실제로 2021년 2월 진입 후보에서는 다음 날 시가 체결의 거래 수익률이 당일 종가 체결보다 약 6.05%p 높았습니다.
중요한 기준은 어느 방식이 더 높은 수익을 냈느냐가 아닙니다. 실제로 주문할 수 있었던 가격인가입니다. 불가능한 당일 종가 체결이 손실을 더 크게 만들었더라도 그 결과는 올바른 백테스트로 인정할 수 없습니다.
6. 승률은 높아졌는데 누적수익률은 낮아진 이유
다음 날 시가 체결의 승률은 42.11%로 당일 종가 체결의 36.84%보다 높았습니다. 하지만 누적수익률은 더 낮았습니다.
승률은 수익 거래의 개수만 셉니다. 한 거래가 0.1% 수익이어도 승리이고, 다른 거래가 20% 수익이어도 똑같이 한 번의 승리입니다. 반면 누적수익률은 각 거래의 손익 크기와 순서를 모두 반영합니다.
이번 결과에서는 몇 차례의 큰 수익 거래가 다음 날 시가 체결에서 줄어든 영향이 승률 상승보다 컸습니다. 누적수익률만 보면 위험을 놓치고, 승률만 보면 손익 크기를 놓칩니다. CAGR, MDD, 평균손익과 거래 수를 함께 봐야 하는 이유입니다.
7. shift(1) 하나만 넣으면 해결된다는 오해
종가 신호를 하루 미루기 위해 다음 코드만 추가하는 경우가 많습니다.
position = signal.shift(1)
방향은 맞지만 이것만으로 체결 모형이 완성되지는 않습니다.
- 이동한 포지션이 어느 가격에 체결됐는지 정해야 합니다.
- 시가에 체결했다면 수익 구간도 시가부터 계산해야 합니다.
- 보유 중 신호를 다시 매수했는지 확인해야 합니다.
- 청산 신호와 청산 체결일도 분리해야 합니다.
- 마지막 날 미청산 포지션 처리 방법을 정해야 합니다.
- 진입과 청산 양쪽에서 비용을 차감해야 합니다.
신호만 한 칸 옮기고 종가 수익률을 그대로 곱하면 신호 시점과 수익 구간이 서로 어긋날 수 있습니다. 가장 안전한 방법은 거래 내역에 entry_signal_date, entry_date, entry_price, exit_signal_date, exit_date, exit_price를 모두 남긴 뒤 몇 건을 직접 확인하는 것입니다.
8. 이번 백테스트가 아직 반영하지 못한 것
다음 날 시가 체결도 실전의 완전한 복제는 아닙니다.
- 시가에 원하는 수량이 모두 체결된다고 가정했습니다.
- 호가 스프레드와 주문 크기에 따른 시장 충격을 개별 계산하지 않았습니다.
- 거래정지, 상·하한가와 부분 체결을 반영하지 않았습니다.
- 0.05% 비용은 실제 계좌 비용이 아닌 비교용 가정입니다.
- 삼성전자 한 종목과 19건의 거래만 사용했습니다.
- yfinance의 과거 데이터 정정이나 조정 방식이 바뀌면 결과가 달라질 수 있습니다.
따라서 71.10%를 앞으로 기대할 수익률로 해석하면 안 됩니다. 이번 숫자는 같은 전략 안에서 체결 시점이 결과에 미치는 영향을 보여주는 비교값입니다.
9. 마치며: 높은 수익률보다 먼저 가능한 주문인지 확인한다
이번 실험에서 확인한 결론은 세 가지입니다.
- 종가로 계산한 신호를 같은 종가에 체결하면 시간 순서가 뒤집힙니다.
- 체결 시점 하나만 바꿔도 누적수익률, MDD와 개별 거래 결과가 달라집니다.
- 신호일과 체결일을 분리한 거래 내역이 있어야 백테스트를 검증할 수 있습니다.
백테스트 결과가 좋게 나왔을 때 가장 먼저 확인할 질문은 “수익률이 얼마인가”가 아닙니다. “이 가격에 실제로 주문할 수 있었는가”입니다.
다음 글에서는 신호를 만들기 전에 어떤 종목을 백테스트 대상에 넣을지 정합니다. 눈에 띄는 종목을 사후 선택하지 않고 거래대금과 변동성 조건으로 투자 대상을 거르는 방법을 살펴봅니다.
이 글의 백테스트는 체결 시점의 영향을 설명하기 위한 교육용 예시입니다. 과거 성과는 미래 수익을 보장하지 않으며 실제 투자 판단과 책임은 투자자 본인에게 있습니다.