아무 종목이나 돌렸더니 498번 매매, 거래대금·ATR로 거르는 법

종목 이름만 바꿔 같은 전략을 실행하면 백테스트 범위는 쉽게 넓어집니다. 하지만 신호가 나온 종목을 모두 거래 대상으로 삼으면 수익률보다 먼저 다른 문제가 생깁니다. 거래가 드문 종목은 원하는 가격에 사고팔기 어렵고, 지나치게 조용한 종목은 기회가 부족하며, 변동성이 과도한 종목은 다음 시가에서 예상보다 멀리 체결될 수 있습니다.

이전 글인 종가 신호를 바로 샀더니 82.9%, 파이썬 백테스트가 틀어진 이유에서는 신호가 확정된 뒤 다음 거래일 시가에 주문해야 한다는 점을 확인했습니다. 이번에는 그 주문을 어느 종목에 낼 것인지 정합니다.

국내 주식 30종목에 같은 눌림목 전략을 적용했더니 필터가 없을 때 498건의 거래가 만들어졌습니다. 진입 직전 거래대금과 ATR 조건을 추가하자 384건만 남았습니다. 제외된 114건이 단순히 나쁜 거래였는지, 아니면 애초에 실행 조건과 맞지 않았는지 차례로 살펴보겠습니다.

1. 종목 선택은 전략보다 먼저 정해야 한다

백테스트에서 종목을 고르는 기준이 없으면 결과가 쉽게 흔들립니다.

  • 차트가 좋아 보였던 종목만 사후에 넣을 수 있습니다.
  • 거래량이 적어 실제 주문이 어려운 종목도 종가 한 가격에 전량 체결된 것으로 계산됩니다.
  • 변동성이 거의 없는 종목과 급등락 종목이 같은 전략 안에 섞입니다.
  • 수익이 잘 나온 종목을 추가할 때마다 전체 누적수익률이 올라갑니다.

따라서 전략의 진입 조건과 별도로 거래 가능한 종목 조건이 필요합니다. 이번 실험에서는 다음 두 기준만 사용합니다.

조건 규칙 확인하려는 문제
유동성 직전 20일 중앙 거래대금 100억 원 이상 주문 크기에 비해 거래가 너무 적지 않은가
변동성 직전 ATR(14)이 주가의 1.5~6.0% 너무 조용하거나 과도하게 흔들리지 않는가

필터 숫자는 모든 전략에 통하는 정답이 아닙니다. 주문 금액, 보유 기간과 종목군에 맞춰 조정할 시작점입니다.

2. 거래량보다 거래대금을 보는 이유

거래량 10만 주는 주가가 1,000원인 종목과 10만 원인 종목에서 의미가 다릅니다. 거래량만 비교하면 실제로 시장에서 오간 자금 규모를 놓칩니다.

TEXT
일 거래대금 = 종가 × 거래량
20일 중앙 거래대금 = 직전 20거래일 일 거래대금의 중앙값

평균 대신 중앙값을 사용한 이유는 하루의 거래량 폭증이 평소 유동성을 과장하지 않게 하기 위해서입니다. 계산에는 shift(1)을 적용해 신호가 발생한 날의 거래대금을 필터에 넣지 않았습니다. 주문을 결정하기 전에 이미 알 수 있었던 값만 사용합니다.

거래대금 필터를 통과했다고 실제 체결이 보장되는 것은 아닙니다. 일봉 데이터에는 매수·매도 호가, 스프레드, 잔량과 부분 체결이 없기 때문입니다. 거래대금은 호가 데이터를 받기 전 사용할 수 있는 1차 필터입니다.

3. 변동성은 높을수록 좋은 것이 아니다

변동성이 작으면 가격이 움직일 기회가 부족하지만, 크다고 무조건 유리한 것도 아닙니다. 급등락이 반복되는 종목은 신호 종가와 다음 시가의 차이가 커질 수 있고 손절 가격도 멀어집니다.

서로 가격이 다른 종목을 비교하기 위해 ATR 자체가 아니라 ATR ÷ 종가 × 100을 사용했습니다.

ATR 비율 이번 실험의 처리 의미
1.5% 미만 변동성 부족 움직임이 작아 전략의 기대 구간이 좁음
1.5~6.0% 통과 예시 전략이 다룰 범위
6.0% 초과 변동성 과다 갭과 손익 변동 확대 가능성

실제 결과에서도 변동성 과다 그룹의 다음 시가 절대 괴리 중앙값은 1.81%, 상위 10% 값은 3.88%였습니다. 필터 통과 그룹은 각각 0.75%, 2.76%였습니다. 이 괴리에는 밤사이 뉴스와 시장 방향도 섞이므로 호가 슬리피지와 같은 값은 아닙니다. 다만 종가 신호를 다음 시가 주문으로 바꿀 때 감수할 가격 불확실성을 보여줍니다.

4. 비교 조건을 고정하기

종목 필터 외의 조건은 이전 글과 같게 유지했습니다.

항목 설정
대상 KOSPI 18종목, KOSDAQ 12종목의 고정 예시 목록
데이터 yfinance 수정 일봉
검증 구간 코드에 지정한 8개년 고정 구간
진입 상승 추세, 20일선 회복, 거래량 증가가 동시에 발생
청산 종가가 20일선을 하향 이탈
체결 신호 다음 거래일 시가
종목 비중 30종목 동일 초기 비중
비용 매수·매도 각각 0.05%의 예시 비용
비교 필터 없음 / 거래대금·ATR 필터

이 30종목은 시장 전체나 특정 지수의 완전한 구성 종목이 아닙니다. yfinance로 코드를 바로 실행할 수 있도록 고정한 예시 목록입니다. 상장폐지 종목까지 포함한 과거 시점별 유니버스가 아니므로 생존 편향을 제거한 시장 전체 검증으로 해석하면 안 됩니다.

5. 전체 Python 코드

필요한 패키지가 없다면 터미널에서 먼저 설치합니다.

TEXT
python -m pip install yfinance pandas numpy matplotlib

아래 블록 하나에 데이터 수집, 필터 계산, 다음 시가 체결 백테스트, 결과 저장과 차트 생성이 모두 들어 있습니다.

PYTHON
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import yfinance as yf
from matplotlib import font_manager


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"]
START_DATE = "2018-01-01"
END_DATE = "2026-01-01"
TURNOVER_FLOOR = 10_000_000_000
ATR_MIN = 1.5
ATR_MAX = 6.0
ORDER_VALUE = 10_000_000
VOLUME_MULTIPLIER = 1.2
COST_RATE = 0.0005
CHART_FILE = Path("universe_filter_chart.png")
RESULT_FILE = Path("universe_filter_results.csv")


def prepare_price(raw: pd.DataFrame, ticker: str) -> pd.DataFrame:
    data = raw[ticker].rename(columns=str.lower).dropna().copy()
    data["sma_20"] = data["close"].rolling(20).mean()
    data["sma_60"] = data["close"].rolling(60).mean()
    data["sma_120"] = data["close"].rolling(120).mean()
    data["volume_average_20"] = data["volume"].shift(1).rolling(20).mean()
    data["entry_signal"] = (data["sma_60"] > data["sma_120"]) & (data["close"] > data["sma_120"]) & (data["close"] > data["sma_20"]) & (data["close"].shift(1) <= data["sma_20"].shift(1)) & (data["volume"] >= data["volume_average_20"] * VOLUME_MULTIPLIER)
    data["exit_signal"] = (data["close"] < data["sma_20"]) & (data["close"].shift(1) >= data["sma_20"].shift(1))
    previous_close = data["close"].shift(1)
    true_range = pd.concat([data["high"] - data["low"], (data["high"] - previous_close).abs(), (data["low"] - previous_close).abs()], axis=1).max(axis=1)
    data["turnover_20"] = (data["close"] * data["volume"]).shift(1).rolling(20).median()
    data["atr_percent"] = true_range.shift(1).rolling(14).mean() / data["close"].shift(1) * 100.0
    data["eligible"] = (data["turnover_20"] >= TURNOVER_FLOOR) & data["atr_percent"].between(ATR_MIN, ATR_MAX)
    data["next_open_gap"] = (data["open"].shift(-1) / data["close"] - 1.0).abs()
    return data.dropna(subset=["sma_120", "turnover_20", "atr_percent"])


def run_backtest(data: pd.DataFrame, use_filter: bool) -> tuple[pd.DataFrame, pd.DataFrame]:
    cash = 1.0
    shares = 0.0
    pending_order = None
    current_trade = None
    trades = []
    equity_records = []

    for date, row in 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"]):
            pending_order = {"side": "sell", "signal_date": date}
        elif shares == 0.0 and bool(row["entry_signal"]) and (bool(row["eligible"]) or not use_filter):
            pending_order = {"side": "buy", "signal_date": date}

        equity_records.append({"date": date, "equity": cash + shares * float(row["close"])})

    if shares > 0.0:
        last_date = data.index[-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

    return pd.DataFrame(trades), pd.DataFrame(equity_records).set_index("date")


def build_portfolio(price_by_ticker: dict[str, pd.DataFrame], calendar: pd.DatetimeIndex, use_filter: bool) -> tuple[pd.DataFrame, pd.DataFrame]:
    equity_series = []
    trade_frames = []
    for ticker, data in price_by_ticker.items():
        trades, equity = run_backtest(data, use_filter)
        equity_series.append(equity["equity"].reindex(calendar).ffill().fillna(1.0).rename(ticker))
        if not trades.empty:
            trades.insert(0, "ticker", ticker)
            trade_frames.append(trades)
    portfolio = pd.concat(equity_series, axis=1).mean(axis=1).to_frame("equity")
    combined_trades = pd.concat(trade_frames, ignore_index=True)
    return combined_trades, portfolio


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(trades["trade_return"].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

raw = yf.download(TICKERS, start=START_DATE, end=END_DATE, auto_adjust=True, progress=False, group_by="ticker", threads=False)
if raw.empty:
    raise RuntimeError("가격 데이터를 받지 못했습니다.")

price_by_ticker = {ticker: prepare_price(raw, ticker) for ticker in TICKERS}
candidate_frames = []
for ticker, data in price_by_ticker.items():
    candidate = data.loc[data["entry_signal"], ["turnover_20", "atr_percent", "next_open_gap"]].copy()
    candidate["ticker"] = ticker
    candidate_frames.append(candidate)
candidate_signals = pd.concat(candidate_frames)
candidate_signals["group"] = np.select([candidate_signals["turnover_20"] < TURNOVER_FLOOR, candidate_signals["atr_percent"] < ATR_MIN, candidate_signals["atr_percent"] > ATR_MAX], ["거래대금 미달", "변동성 부족", "변동성 과다"], default="필터 통과")

unfiltered_trades, unfiltered_equity = build_portfolio(price_by_ticker, raw.index, False)
filtered_trades, filtered_equity = build_portfolio(price_by_ticker, raw.index, True)
unfiltered_summary = summarize(unfiltered_trades, unfiltered_equity)
filtered_summary = summarize(filtered_trades, filtered_equity)
summary = pd.DataFrame([unfiltered_summary, filtered_summary], index=["필터 없음", "거래대금·ATR 필터"])
summary["거래수"] = summary["거래수"].astype(int)
summary.to_csv(RESULT_FILE, encoding="utf-8-sig")

group_order = ["필터 통과", "거래대금 미달", "변동성 과다", "변동성 부족"]
group_summary = candidate_signals.groupby("group").agg(신호수=("next_open_gap", "size"), 중앙거래대금=("turnover_20", "median"), 중앙ATR=("atr_percent", "median"), 중앙시가괴리=("next_open_gap", "median"), 상위10퍼센트시가괴리=("next_open_gap", lambda values: values.quantile(0.9))).reindex(group_order)
pass_participation = ORDER_VALUE / candidate_signals.loc[candidate_signals["group"] == "필터 통과", "turnover_20"].median()
low_turnover_participation = ORDER_VALUE / candidate_signals.loc[candidate_signals["group"] == "거래대금 미달", "turnover_20"].median()

print(summary.to_string(formatters={"누적수익률": "{:.2%}".format, "CAGR": "{:.2%}".format, "MDD": "{:.2%}".format, "승률": "{:.2%}".format, "평균거래수익률": "{:.2%}".format}))
print(group_summary.to_string(formatters={"중앙거래대금": lambda value: f"{value / 100_000_000:.1f}억", "중앙ATR": "{:.2f}%".format, "중앙시가괴리": "{:.2%}".format, "상위10퍼센트시가괴리": "{:.2%}".format}))
print(f"1,000만 원 주문 참여율 중앙값: 통과 {pass_participation:.3%}, 거래대금 미달 {low_turnover_participation:.3%}")

fig = plt.figure(figsize=(13, 9), facecolor="white")
grid = fig.add_gridspec(2, 2, height_ratios=[2.3, 1.4], hspace=0.35, wspace=0.22)
equity_axis = fig.add_subplot(grid[0, :])
count_axis = fig.add_subplot(grid[1, 0])
participation_axis = fig.add_subplot(grid[1, 1])

equity_axis.plot(unfiltered_equity.index, unfiltered_equity["equity"], color="#F59E0B", linewidth=2.0, label=f"필터 없음 {unfiltered_summary['누적수익률']:.1%}")
equity_axis.plot(filtered_equity.index, filtered_equity["equity"], color="#0F766E", linewidth=2.2, label=f"거래대금·ATR 필터 {filtered_summary['누적수익률']:.1%}")
equity_axis.set_title("같은 전략, 종목 필터 전후 비교", fontsize=18, fontweight="bold", pad=16)
equity_axis.set_ylabel("30종목 동일 비중 누적 자산")
equity_axis.legend(loc="upper left", frameon=False)

bar_colors = ["#0F766E", "#F59E0B", "#DC2626", "#64748B"]
count_bars = count_axis.bar(group_order, group_summary["신호수"], color=bar_colors)
count_axis.set_title("진입 후보가 제외된 이유", fontsize=14, fontweight="bold")
count_axis.set_ylabel("신호 수")
count_axis.tick_params(axis="x", rotation=12)
count_axis.bar_label(count_bars, padding=3)

participation_values = [pass_participation * 100.0, low_turnover_participation * 100.0]
participation_bars = participation_axis.bar(["필터 통과", "거래대금 미달"], participation_values, color=["#0F766E", "#F59E0B"])
participation_axis.set_title("1,000만 원 주문의 거래대금 참여율", fontsize=14, fontweight="bold")
participation_axis.set_ylabel("20일 중앙 거래대금 대비(%)")
participation_axis.bar_label(participation_bars, labels=[f"{value:.3f}%" for value in participation_values], padding=3)

for axis in (equity_axis, count_axis, participation_axis):
    axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
    axis.spines["top"].set_visible(False)
    axis.spines["left"].set_visible(False)
    axis.spines["right"].set_color("#D1D5DB")
    axis.spines["bottom"].set_color("#D1D5DB")
    axis.yaxis.tick_right()
    axis.yaxis.set_label_position("right")

fig.savefig(CHART_FILE, dpi=160, bbox_inches="tight", facecolor="white")
plt.show()

turnover_20atr_percent에는 모두 한 거래일 이전 값만 들어갑니다. 신호가 나온 뒤 그날 거래대금과 변동성을 보고 통과 여부를 바꾸는 미래 참조를 피하기 위해서입니다.

각 종목은 독립적인 초기 자산 1로 백테스트하고, 30개 자산곡선을 같은 비중으로 평균했습니다. 일부 종목의 상장 전 구간은 현금 1로 유지해 뒤늦게 상장한 종목이 과거 수익률에 영향을 주지 않게 했습니다.

6. 498건 가운데 114건이 제외됐다

실행 결과에서 진입 후보는 다음처럼 나뉘었습니다.

구분 신호 수 중앙 거래대금 중앙 ATR 비율 시가 괴리 상위 10%
필터 통과 384건 485.6억 원 3.03% 2.76%
거래대금 미달 81건 60.4억 원 3.84% 2.93%
변동성 과다 23건 541.0억 원 6.83% 3.88%
변동성 부족 10건 406.0억 원 1.28% 1.02%
같은 전략에 거래대금과 ATR 종목 필터를 적용한 결과

거래대금 미달 그룹의 시가 괴리 중앙값은 0.73%로 필터 통과 그룹의 0.75%와 거의 같았습니다. 이 결과만으로 거래대금이 적으면 다음 시가 갭이 반드시 커진다고 말할 수 없습니다.

차이는 주문 크기에서 더 분명했습니다. 1,000만 원 주문이 직전 20일 중앙 거래대금에서 차지하는 비중은 필터 통과 그룹이 0.021%, 거래대금 미달 그룹이 0.166%였습니다. 후자가 약 8배 큽니다.

이 비율도 실제 시장 충격을 계산한 값은 아닙니다. 같은 일 거래대금이라도 호가 잔량과 스프레드에 따라 체결 결과가 다릅니다. 다만 주문 금액을 늘릴수록 어느 그룹이 먼저 체결 한계에 가까워지는지 확인하는 용량 지표로 사용할 수 있습니다.

7. 거래 수를 줄였는데 누적수익률과 MDD가 함께 개선됐다

같은 비용과 다음 시가 체결을 적용한 결과입니다.

구분 누적수익률 CAGR MDD 승률 평균 거래 수익률 거래 수
필터 없음 35.66% 3.89% -9.36% 30.52% 2.73% 498회
거래대금·ATR 필터 40.62% 4.36% -7.83% 32.55% 3.23% 384회

필터를 적용하자 거래 수는 22.9% 줄었고 누적수익률은 4.96%p 높아졌습니다. MDD도 -9.36%에서 -7.83%로 줄었습니다.

그렇다고 거래대금 100억 원과 ATR 1.5~6.0%가 수익을 높이는 공식이라고 결론 내리면 안 됩니다. 이 숫자는 같은 30종목과 같은 구간에서 나온 표본 결과입니다. 필터 경계를 바꾸거나 다른 종목군을 사용하면 필터 적용 수익률이 더 낮아질 수도 있습니다.

중요한 변화는 수익률보다 먼저 확인됩니다.

  • 거래할 이유가 약한 후보 114건이 주문 단계 전에 제거됐습니다.
  • 변동성 과다 그룹의 큰 시가 괴리가 별도로 드러났습니다.
  • 고정 주문 금액이 종목 거래대금에서 차지하는 비율을 확인할 수 있게 됐습니다.
  • 종목을 사후 선택하지 않고 진입 전에 적용할 규칙이 생겼습니다.

8. 실전에서는 주문 금액에 맞춰 기준을 바꾼다

거래대금 하한은 계좌 크기와 함께 움직여야 합니다. 100만 원 주문과 1억 원 주문에 같은 기준을 적용하면 체결 위험이 달라집니다.

TEXT
주문 참여율 = 주문 금액 ÷ 직전 20일 중앙 거래대금

예를 들어 최대 참여율을 0.1%로 정한다면 1,000만 원 주문에는 중앙 거래대금 100억 원 이상이 필요합니다. 주문 금액이 5,000만 원이라면 같은 참여율을 유지하기 위해 500억 원 이상으로 올려야 합니다.

실전 필터는 다음 순서로 확장할 수 있습니다.

  1. 거래대금으로 기본 유동성을 확인합니다.
  2. ATR 비율로 전략이 다룰 움직임 범위를 정합니다.
  3. 호가 데이터가 있다면 스프레드와 주문 잔량을 추가합니다.
  4. 거래정지, 관리종목, 상장 기간과 가격 제한 조건을 넣습니다.
  5. 주문 금액을 바꿔 참여율과 비용 민감도를 다시 계산합니다.

9. 이번 실험의 한계

  • 고정한 30종목은 시장 전체와 과거 시점별 구성 종목을 대표하지 않습니다.
  • 상장폐지 종목이 없어 생존 편향이 남아 있습니다.
  • yfinance의 수정 데이터와 과거 데이터 정정에 따라 세부 수치는 달라질 수 있습니다.
  • 일봉 거래대금만으로 호가 스프레드, 잔량, 부분 체결과 시장 충격을 계산할 수 없습니다.
  • 1,000만 원 주문 참여율은 체결 위험의 대리 지표이며 실제 슬리피지 값이 아닙니다.
  • 0.05% 비용은 특정 증권사의 실제 수수료나 세금이 아닌 비교용 가정입니다.
  • 필터 경계는 이 표본에서 최적화하지 않았으며 다른 전략에 그대로 적용할 기준이 아닙니다.

10. 마치며: 신호보다 먼저 거래 가능한 종목을 정한다

이번 실험에서 확인한 결론은 세 가지입니다.

  1. 종목 필터 없이 같은 전략을 확장하면 체결 가능성이 다른 신호가 한 결과에 섞입니다.
  2. 거래대금은 주문 용량을, ATR 비율은 전략이 감당할 움직임 범위를 정하는 1차 기준이 됩니다.
  3. 필터 적용 후 수익률이 좋아졌더라도 다른 종목과 구간에서 다시 검증해야 합니다.

좋은 종목을 예측하는 것이 이번 필터의 목적은 아닙니다. 백테스트에 들어가기 전에 이 종목을 이 주문 금액으로 실제 거래할 수 있는가를 코드로 확인하는 것이 목적입니다.

다음 글에서는 누적수익률 하나로 전략을 평가하지 않고 CAGR, MDD, Profit Factor, 평균손익비, 거래 수와 노출 비중을 한 장의 성과표로 정리합니다.


이 글의 백테스트는 종목 필터가 결과와 체결 가능성에 미치는 영향을 설명하기 위한 교육용 예시입니다. 과거 성과는 미래 수익을 보장하지 않으며 실제 투자 판단과 책임은 투자자 본인에게 있습니다.