ROC·ADX 전략: 거래를 줄여도 가짜 신호가 남는 이유

ROC가 0선 위로 올라오면 가격의 상승 속도가 양수로 바뀌었다는 뜻입니다. 하지만 횡보장에서도 ROC는 기준선을 자주 오갑니다. 신호가 나올 때마다 매수하면 짧은 손실이 반복되기 쉽습니다.

이 문제를 줄이기 위해 흔히 추세 강도 지표인 ADX를 추가합니다. ROC가 상승을 알릴 때 ADX도 오르고, +DI가 -DI보다 큰 경우만 진입하는 방식입니다.

국내 주식 30종목에 같은 규칙을 적용하자 거래 수는 2,834건에서 845건으로 70.18% 감소했습니다. 그러나 진입 후 20거래일 이내 손실 청산으로 정의한 가짜 신호의 비율은 60.59%에서 57.28%로 3.31%p만 낮아졌습니다.

거래를 많이 거른 것과 나쁜 거래를 정확하게 거른 것은 다른 결과입니다.

SYSTEM LAB 07 핵심 결과

  • 전체 거래 2,834건 → 845건
  • 20거래일 이내 손실 1,717건 → 484건
  • 가짜 신호 비율 60.59% → 57.28%
  • MDD -23.58% → -12.24%
  • 누적수익률 +366.06% → +88.32%

1. ROC는 방향을, ADX는 추세 강도를 본다

ROC(Rate of Change)는 현재 종가가 일정 기간 전보다 얼마나 변했는지 계산합니다.

ROC(20) = (현재 종가 / 20거래일 전 종가 - 1) × 100

ROC가 0보다 크면 현재 가격이 20거래일 전보다 높고, 0보다 작으면 낮습니다. 이번 실험에서는 작은 등락에 바로 진입하지 않도록 ROC가 +2%를 처음 넘어설 때 매수 후보를 만듭니다. 청산은 ROC가 다시 0 아래로 내려올 때로 고정합니다.

ADX는 방향 자체가 아니라 방향성 움직임의 강도를 측정합니다. 그래서 ADX만 높다고 상승 추세라고 판단할 수 없습니다. 방향을 구분하려면 DMI의 두 선을 함께 봐야 합니다.

  • +DI > -DI: 상승 방향의 움직임이 상대적으로 강한 상태
  • -DI > +DI: 하락 방향의 움직임이 상대적으로 강한 상태
  • ADX 상승: 어느 한 방향으로 움직이는 힘이 커지는 상태

필터 전략은 ROC 진입 후보가 발생한 날 ADX가 2거래일 전보다 높고 +DI가 -DI보다 큰가를 추가로 확인합니다. ADX의 특정 절대값을 최적화하지 않고 방향과 강도라는 역할만 결합했습니다.

ROC 지표의 계산 구조, DMI의 +DI와 -DI 교차, ADX로 추세 강도를 읽는 법을 먼저 보면 세 조건의 역할을 구분하기 쉽습니다.

2. 가짜 신호를 먼저 정의해야 한다

가짜 신호는 차트 모양만 보고 사후적으로 고르면 안 됩니다. 결과를 보기 전에 같은 기준으로 분류해야 두 전략을 비교할 수 있습니다.

이번 글에서는 다음 두 조건을 모두 만족한 거래를 가짜 신호로 정의합니다.

  1. 매수 후 청산까지 20거래일 이하입니다.
  2. 매수·매도 비용을 반영한 거래 수익률이 음수입니다.

이 정의는 상승 모멘텀이 생긴 것처럼 보였지만 한 달 안에 꺾여 손실로 끝난 거래를 세기 위한 운영 기준입니다. 모든 사람이 따라야 할 정답은 아닙니다. 10일이나 40일로 바꾸면 가짜 신호 수와 비율도 달라집니다.

중요한 점은 ROC 단독과 필터 전략에 정확히 같은 정의를 적용했다는 것입니다.

3. 비교 조건을 하나만 바꾼다

구분 ROC 단독 ROC + ADX·DMI
입력 데이터 yfinance 수정주가 일봉 동일
대상 국내 주식 30종목 동일
진입 후보 ROC(20)이 +2%를 처음 상향 돌파 동일
추가 확인 없음 ADX(14) 상승, +DI > -DI
청산 ROC(20)이 0% 하향 이탈 동일
체결 종가 신호 확정 후 다음 거래일 시가 동일
비용 매수·매도 각각 0.05% 동일
중복 진입 보유 중 추가 진입 금지 동일

2014년부터 2025년까지를 검증 구간으로 두고, 지표 계산에 필요한 앞선 데이터는 워밍업으로만 사용했습니다. 각 종목을 1의 자산으로 시작한 뒤 30개 자산곡선을 동일 비중으로 평균했습니다.

ADX 필터 외의 진입 기준과 청산을 바꾸지 않은 이유는 간단합니다. 손절, 보유 기간과 추적 청산까지 동시에 넣으면 어떤 규칙이 결과를 바꿨는지 알기 어려워집니다.

4. 전체 Python 코드

필요한 패키지는 pandas, numpy, matplotlib, yfinance입니다. 아래 코드는 데이터 수집, ROC·ADX·DMI 계산, 다음 시가 체결, 비용 반영, 결과표와 차트 생성까지 한 번에 실행합니다.

PYTHON
#!/usr/bin/env python3
"""ROC 진입 신호에 ADX·DMI 필터를 추가했을 때의 손익을 비교한다."""

from __future__ import annotations

from pathlib import Path
import time

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"]
DOWNLOAD_START = "2013-01-01"
TEST_START = "2014-01-01"
TEST_END = "2026-01-01"
ROC_WINDOW = 20
ROC_ENTRY_LEVEL = 2.0
ADX_WINDOW = 14
COST_RATE = 0.0005
FALSE_SIGNAL_DAYS = 20
MIN_ROWS = 252
CHART_FILE = Path("roc_adx_chart.png")
RESULT_FILE = Path("roc_adx_results.csv")
SIGNAL_FILE = Path("roc_adx_signals.csv")

def wilder_average(values: pd.Series, window: int) -> pd.Series:
    return values.ewm(alpha=1.0 / window, adjust=False, min_periods=window).mean()

def download_prices() -> dict[str, pd.DataFrame]:
    prices = {}
    for ticker in TICKERS:
        frame = pd.DataFrame()
        for attempt in range(3):
            if not frame.empty and len(frame.loc[(frame.index >= TEST_START) & (frame.index < TEST_END)]) >= MIN_ROWS:
                break
            retry = yf.download(ticker, start=DOWNLOAD_START, end=TEST_END, auto_adjust=True, progress=False, threads=False)
            if isinstance(retry.columns, pd.MultiIndex):
                retry.columns = retry.columns.get_level_values(0)
            frame = retry.rename(columns=str.lower).dropna(subset=["open", "high", "low", "close"]).copy()
            time.sleep(1.0 + attempt)
        if len(frame.loc[(frame.index >= TEST_START) & (frame.index < TEST_END)]) < MIN_ROWS:
            raise RuntimeError(f"검증에 필요한 가격 데이터가 부족합니다: {ticker}")
        prices[ticker] = frame
    return prices

def prepare_price(prices: dict[str, pd.DataFrame], ticker: str) -> pd.DataFrame:
    data = prices[ticker].copy()
    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)
    upward_move = data["high"].diff()
    downward_move = -data["low"].diff()
    plus_dm = upward_move.where((upward_move > downward_move) & (upward_move > 0.0), 0.0)
    minus_dm = downward_move.where((downward_move > upward_move) & (downward_move > 0.0), 0.0)
    average_true_range = wilder_average(true_range, ADX_WINDOW)
    data["plus_di"] = 100.0 * wilder_average(plus_dm, ADX_WINDOW) / average_true_range
    data["minus_di"] = 100.0 * wilder_average(minus_dm, ADX_WINDOW) / average_true_range
    dx = 100.0 * (data["plus_di"] - data["minus_di"]).abs() / (data["plus_di"] + data["minus_di"])
    data["adx"] = wilder_average(dx.replace([np.inf, -np.inf], np.nan), ADX_WINDOW)
    data["roc"] = data["close"].pct_change(ROC_WINDOW) * 100.0
    positive_episode = (data["roc"] <= 0.0).cumsum()
    entry_candidate = data["roc"] > ROC_ENTRY_LEVEL
    previous_candidate = entry_candidate.groupby(positive_episode).cummax().groupby(positive_episode).shift(1, fill_value=False)
    data["roc_entry_signal"] = entry_candidate & ~previous_candidate
    data["adx_filter"] = (data["adx"] > data["adx"].shift(2)) & (data["plus_di"] > data["minus_di"])
    data["filtered_entry_signal"] = data["roc_entry_signal"] & data["adx_filter"]
    data["exit_signal"] = (data["roc"] < 0.0) & (data["roc"].shift(1) >= 0.0)
    return data.loc[(data.index >= TEST_START) & (data.index < TEST_END)].copy()

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

    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_index": index, "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
                trade_return = cash / current_trade["capital_before"] - 1.0
                holding_days = index - current_trade["entry_index"]
                trades.append({**current_trade, "exit_signal_date": pending_order["signal_date"], "exit_date": date, "exit_price": exit_price, "trade_return": trade_return, "holding_days": holding_days, "false_signal": trade_return < 0.0 and holding_days <= FALSE_SIGNAL_DAYS, "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_column]):
            pending_order = {"side": "buy", "signal_date": date}

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

    if shares > 0.0:
        last_index = len(data) - 1
        last_date = data.index[-1]
        last_close = float(data.iloc[-1]["close"])
        cash = shares * last_close * (1.0 - COST_RATE)
        trade_return = cash / current_trade["capital_before"] - 1.0
        holding_days = last_index - current_trade["entry_index"]
        trades.append({**current_trade, "exit_signal_date": last_date, "exit_date": last_date, "exit_price": last_close, "trade_return": trade_return, "holding_days": holding_days, "false_signal": trade_return < 0.0 and holding_days <= FALSE_SIGNAL_DAYS, "forced_exit": True})
        daily_records[-1]["equity"] = cash
        daily_records[-1]["exposure"] = 0.0

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

def analyze_roc_signals(data: pd.DataFrame, baseline_trades: pd.DataFrame, ticker: str) -> pd.DataFrame:
    rows = []
    for trade in baseline_trades.itertuples():
        signal = data.loc[trade.entry_signal_date]
        rows.append({"ticker": ticker, "entry_signal_date": trade.entry_signal_date, "exit_signal_date": trade.exit_signal_date, "trade_return": trade.trade_return, "holding_days": trade.holding_days, "false_signal": trade.false_signal, "accepted": bool(signal["adx_filter"]), "roc": signal["roc"], "adx": signal["adx"], "plus_di": signal["plus_di"], "minus_di": signal["minus_di"]})
    return pd.DataFrame(rows)

def summarize(trades: pd.DataFrame, portfolio: pd.DataFrame) -> dict[str, float]:
    drawdown = portfolio["equity"] / portfolio["equity"].cummax() - 1.0
    winners = trades.loc[trades["trade_return"] > 0.0, "trade_return"]
    losers = trades.loc[trades["trade_return"] < 0.0, "trade_return"]
    return {"누적수익률": float(portfolio["equity"].iloc[-1] - 1.0), "MDD": float(drawdown.min()), "Profit Factor": float(winners.sum() / losers.abs().sum()), "승률": float((trades["trade_return"] > 0.0).mean()), "거래수": len(trades), "가짜신호수": int(trades["false_signal"].sum()), "가짜신호비율": float(trades["false_signal"].mean()), "평균보유일": float(trades["holding_days"].mean()), "노출비중": float(portfolio["exposure"].mean())}

def configure_font() -> None:
    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

def draw_chart(summary: pd.DataFrame, portfolios: dict[str, pd.DataFrame], signals: pd.DataFrame) -> None:
    colors = {"ROC 단독": "#F59E0B", "ROC + ADX·DMI": "#0F766E"}
    fig = plt.figure(figsize=(13, 9), facecolor="white")
    grid = fig.add_gridspec(2, 2, height_ratios=[1.8, 1.2], hspace=0.35, wspace=0.27)
    equity_axis = fig.add_subplot(grid[0, :])
    count_axis = fig.add_subplot(grid[1, 0])
    rejection_axis = fig.add_subplot(grid[1, 1])

    for label, portfolio in portfolios.items():
        equity_axis.plot(portfolio.index, portfolio["equity"], color=colors[label], linewidth=2.3, label=f"{label} {summary.loc[label, '누적수익률']:+.1%}")
    equity_axis.set_title("ADX·DMI 필터는 단기 손실과 좋은 ROC 신호를 함께 거른다", fontsize=18, fontweight="bold", pad=16)
    equity_axis.set_ylabel("30종목 동일 비중 누적 자산")
    equity_axis.legend(loc="upper left", frameon=False)

    labels = list(summary.index)
    x = np.arange(len(labels))
    width = 0.34
    count_axis.bar(x - width / 2, summary["거래수"], width, color="#94A3B8", label="전체 거래")
    count_axis.bar(x + width / 2, summary["가짜신호수"], width, color=[colors[label] for label in labels], label=f"{FALSE_SIGNAL_DAYS}일 이내 손실")
    count_axis.set_xticks(x, labels)
    count_axis.set_title("거래 수와 가짜 신호", fontsize=14, fontweight="bold")
    count_axis.set_ylabel("거래 수")
    count_axis.legend(frameon=False)
    for container in count_axis.containers:
        count_axis.bar_label(container, padding=3)

    rejected = signals.loc[~signals["accepted"]]
    rejection_labels = ["손실", "수익"]
    rejection_values = [int((rejected["trade_return"] < 0.0).sum()), int((rejected["trade_return"] > 0.0).sum())]
    rejection_axis.bar(rejection_labels, rejection_values, color=["#94A3B8", "#F59E0B"], width=0.55)
    rejection_axis.set_title("필터가 거른 ROC 단독 거래", fontsize=14, fontweight="bold")
    rejection_axis.set_ylabel("거래 수")
    for container in rejection_axis.containers:
        rejection_axis.bar_label(container, padding=3)

    for axis in (equity_axis, count_axis, rejection_axis):
        axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
        axis.spines["top"].set_visible(False)
        axis.spines["right"].set_visible(False)

    CHART_FILE.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(CHART_FILE, dpi=160, bbox_inches="tight", facecolor="white")
    plt.close(fig)

configure_font()
prices = download_prices()

trade_frames = {"ROC 단독": [], "ROC + ADX·DMI": []}
equity_series = {"ROC 단독": [], "ROC + ADX·DMI": []}
exposure_series = {"ROC 단독": [], "ROC + ADX·DMI": []}
signal_frames = []
calendar = pd.DatetimeIndex(sorted(set().union(*(frame.loc[(frame.index >= TEST_START) & (frame.index < TEST_END)].index for frame in prices.values()))))
for ticker in TICKERS:
    data = prepare_price(prices, ticker)
    if len(data) < MIN_ROWS or data.dropna(subset=["roc", "adx", "plus_di", "minus_di"]).empty:
        continue
    baseline_trades, baseline_daily = run_backtest(data, "roc_entry_signal")
    filtered_trades, filtered_daily = run_backtest(data, "filtered_entry_signal")
    for label, trades, daily in (("ROC 단독", baseline_trades, baseline_daily), ("ROC + ADX·DMI", filtered_trades, filtered_daily)):
        trades.insert(0, "ticker", ticker)
        trade_frames[label].append(trades)
        equity_series[label].append(daily["equity"].reindex(calendar).ffill().fillna(1.0).rename(ticker))
        exposure_series[label].append(daily["exposure"].reindex(calendar).ffill().fillna(0.0).rename(ticker))
    signal_frames.append(analyze_roc_signals(data, baseline_trades, ticker))

portfolios = {}
all_trades = {}
summary_rows = {}
for label in trade_frames:
    all_trades[label] = pd.concat(trade_frames[label], ignore_index=True)
    portfolios[label] = pd.DataFrame({"equity": pd.concat(equity_series[label], axis=1).mean(axis=1), "exposure": pd.concat(exposure_series[label], axis=1).mean(axis=1)})
    summary_rows[label] = summarize(all_trades[label], portfolios[label])

summary = pd.DataFrame(summary_rows).T
summary[["거래수", "가짜신호수"]] = summary[["거래수", "가짜신호수"]].astype(int)
signals = pd.concat(signal_frames, ignore_index=True)
accepted = signals.loc[signals["accepted"]]
rejected = signals.loc[~signals["accepted"]]
summary.to_csv(RESULT_FILE, encoding="utf-8-sig")
signals.to_csv(SIGNAL_FILE, index=False, encoding="utf-8-sig")
draw_chart(summary, portfolios, signals)

print(summary.to_string(formatters={"누적수익률": "{:+.2%}".format, "MDD": "{:.2%}".format, "Profit Factor": "{:.2f}".format, "승률": "{:.2%}".format, "가짜신호비율": "{:.2%}".format, "평균보유일": "{:.1f}".format, "노출비중": "{:.2%}".format}))
print(f"ROC 단독 거래 중 필터 통과: {len(accepted)}/{len(signals)} ({len(accepted) / len(signals):.2%})")
print(f"필터가 거른 ROC 단독 거래: {len(rejected)}건, 손실 {int((rejected['trade_return'] < 0.0).sum())}건, 수익 {int((rejected['trade_return'] > 0.0).sum())}건")
print(f"거른 거래의 합산 단순수익률: {rejected['trade_return'].sum():+.2%}")
print(f"결과 저장: {RESULT_FILE}")
print(f"신호 저장: {SIGNAL_FILE}")
print(f"차트 저장: {CHART_FILE}")

wilder_average()는 ADX와 DMI 계산에 필요한 Wilder 방식의 평활을 적용합니다. roc_entry_signal은 ROC가 양수인 한 구간에서 첫 +2% 돌파만 남겨 같은 상승 구간의 중복 진입 후보를 줄입니다.

filtered_entry_signal은 원래 ROC 후보에 ADX 상승과 DMI 방향 조건을 동시에 붙입니다. 신호는 당일 종가로 확정하고 pending_order에 저장한 뒤 다음 거래일 시가에서만 체결합니다.

5. 거래는 70% 줄었지만 가짜 신호 비율은 3%p 줄었다

작성 환경에서 실행한 결과입니다.

지표 ROC 단독 ROC + ADX·DMI 변화
누적수익률 +366.06% +88.32% -277.74%p
MDD -23.58% -12.24% +11.34%p 개선
Profit Factor 1.71 1.83 +0.12
승률 35.60% 36.80% +1.20%p
거래 수 2,834건 845건 -1,989건
가짜 신호 수 1,717건 484건 -1,233건
가짜 신호 비율 60.59% 57.28% -3.31%p
평균 보유일 14.1일 18.7일 +4.6일
노출 비중 45.20% 17.95% -27.25%p
ROC 단독과 ADX DMI 필터 전략의 거래 수와 가짜 신호 비교

필터 전략은 거래 수를 70.18% 줄였고 가짜 신호 수도 71.81% 줄였습니다. 숫자만 보면 필터가 매우 효과적으로 보입니다.

하지만 전체 거래가 줄어든 속도와 가짜 신호가 줄어든 속도가 비슷합니다. 그래서 거래 가운데 가짜 신호가 차지하는 비율은 60.59%에서 57.28%로 3.31%p만 낮아졌습니다.

필터가 나쁜 신호만 골라냈다면 가짜 신호 비율이 훨씬 빠르게 내려가야 합니다. 이번 결과는 ADX·DMI가 거래 기회를 크게 줄였지만 ROC의 짧은 손실을 정밀하게 구분한 정도는 제한적이었다는 뜻입니다.

6. MDD가 절반 가까이 줄어든 이유

MDD는 -23.58%에서 -12.24%로 개선됐습니다. ADX 필터가 완벽해서라기보다 시장에 머문 시간이 크게 줄어든 영향도 함께 봐야 합니다.

ROC 단독의 노출 비중은 45.20%였지만 필터 전략은 17.95%였습니다. 필터 전략은 검증 기간의 약 82%를 현금 상태로 보냈습니다. 손실 구간에 참여할 기회가 줄어든 만큼 강한 상승 구간에 참여할 기회도 줄었습니다.

이 차이는 누적수익률과 MDD를 같은 성과표에서 읽는 법과 연결됩니다. 현금 비중이 크게 다른 두 전략의 MDD만 비교하고 필터의 우열을 단정하면 안 됩니다.

Profit Factor는 1.71에서 1.83으로, 승률은 35.60%에서 36.80%로 소폭 개선됐습니다. 위험과 거래 품질에는 긍정적인 변화가 있었지만 거래 기회 감소에 비하면 개선 폭은 크지 않았습니다.

7. 필터는 수익 거래 701건도 건너뛰었다

ROC 단독 전략이 실제로 진입한 거래 가운데 ADX·DMI 조건을 만족하지 못한 사례는 1,999건이었습니다.

필터 조건을 만족하지 못한 ROC 단독 거래 건수
전체 1,999건
손실 거래 1,298건
수익 거래 701건

손실 거래가 더 많았지만 수익 거래도 701건 포함됐습니다. 추세 초입에서는 ROC가 먼저 +2%를 돌파해도 ADX가 아직 상승하지 않거나 +DI와 -DI의 차이가 충분히 벌어지지 않을 수 있습니다.

ADX는 가격 움직임을 평활해 계산하므로 확인이 늦습니다. 이 후행성은 약한 신호를 피하게 해주는 동시에 빠르게 시작한 좋은 추세를 놓치게 합니다.

누적수익률이 +366.06%에서 +88.32%로 낮아진 이유도 여기에 있습니다. 필터가 거른 수익 거래 중 일부는 여러 번의 작은 손실을 만회하는 큰 추세였습니다. 추세추종 전략에서는 승률보다 소수의 큰 수익 거래를 남기는 일이 중요할 수 있습니다.

8. ADX 필터는 언제 의미가 있는가

이번 결과만으로 ADX가 쓸모없다고 결론 내릴 수는 없습니다. 전략의 목표에 따라 같은 변화의 의미가 달라집니다.

목표 이번 실험에서의 판단
거래 빈도를 크게 줄이기 효과가 분명함
시장 노출과 MDD 줄이기 효과가 있었음
가짜 신호 비율 크게 낮추기 개선 폭이 제한적
강한 추세 초입 놓치지 않기 불리함
누적수익률 최대화 ROC 단독이 앞섬

주문을 자주 낼 수 없거나 종목별 최대 보유 수를 제한하는 시스템이라면 거래 후보를 줄이는 필터 자체가 가치가 있습니다. 반대로 충분한 종목을 분산해 작은 손실을 감수하고 큰 추세를 기다리는 전략이라면 지나치게 강한 확인 조건이 핵심 수익 기회를 없앨 수 있습니다.

필터를 평가할 때는 손실 거래가 몇 건 줄었는가만 보지 말고 다음 세 숫자를 함께 확인해야 합니다.

  1. 전체 거래 감소율
  2. 가짜 신호 비율의 변화
  3. 시장 노출과 누적수익률의 변화

9. 이번 실험의 한계

첫째, 가짜 신호를 20거래일 이내 손실로 정의했습니다. 기간이나 손실 기준을 바꾸면 비율도 달라집니다.

둘째, ROC +2%, ROC 20일과 ADX 14일은 전략의 유일한 정답이 아닙니다. 이번 글에서는 결과가 가장 좋은 조합을 찾지 않고 하나의 고정 규칙만 비교했습니다.

셋째, 현재 확인 가능한 대형·유동성 종목 30개를 사용해 상장폐지 종목이 빠진 생존 편향이 남아 있습니다.

넷째, yfinance의 수정주가 일봉과 가정 비용을 사용했습니다. 데이터 제공처가 과거 수정주가를 보정하면 세부 거래 수와 결과가 달라질 수 있으며, 실제 세금·스프레드·시장 충격과 주문 실패를 모두 반영한 결과는 아닙니다.

다섯째, ADX는 진입 필터로만 사용했습니다. ADX 하락 반전 청산이나 포지션 크기 조절에 사용하면 전략의 성격과 결과가 달라집니다.

10. 정리

이번 ROC와 ADX·DMI 결합 실험에서 확인한 사실은 세 가지입니다.

  1. 거래 수는 70.18% 줄었지만 가짜 신호 비율은 3.31%p만 낮아졌습니다.
  2. MDD와 시장 노출은 크게 줄었지만 누적수익률도 277.74%p 낮아졌습니다.
  3. 필터는 손실 거래와 함께 수익 거래 701건도 건너뛰었습니다.

지표를 하나 더 넣었다는 사실보다 중요한 것은 그 조건이 전체 거래보다 나쁜 거래를 더 빠르게 줄였는지 확인하는 것입니다. 많이 거른 필터가 반드시 잘 거른 필터는 아닙니다.

다음 글에서는 과매도처럼 보일 때마다 매수하는 CCI 역추세 전략을 다룹니다. 단순 CCI 반등 신호와 추세·변동성 조건을 추가한 규칙을 비교해 물타기로 이어지는 구간을 확인합니다.

이 글은 과거 데이터를 이용한 백테스트 예제이며 특정 종목이나 전략의 미래 수익을 보장하지 않습니다. 실제 투자에서는 세금, 수수료, 슬리피지, 체결 가능성과 계좌 위험을 별도로 검토해야 합니다.


본 포스팅의 백테스트 결과는 과거 데이터와 가정된 거래비용을 바탕으로 한 예시이며 미래 성과를 보장하지 않습니다. 특정 종목이나 매매전략의 추천이 아니며 투자 판단과 책임은 투자자 본인에게 있습니다.