자동매매를 언제 멈추고 다시 켜야 할까?

자동매매 전략도 손실이 이어지면 끄고 싶어집니다. 세 달 연속 손실이면 전략이 망가졌다고 느끼고, 다른 전략보다 부진하면 포트폴리오에서 빼고 싶어집니다.

문제는 손실이 시작된 뒤 기준을 만들면 판단이 계속 바뀐다는 점입니다. 손실이 조금 회복되면 다시 켜고, 다시 밀리면 더 낮은 곳에서 끄는 식으로 시스템보다 사람의 감정이 운용 결과를 결정하게 됩니다.

이번에는 국내 주식 30종목에 적용한 6개 전략을 한 날짜씩 순서대로 재생했습니다. 개발 구간의 정보만으로 중지선을 정하고, 이후 구간에서는 미래 데이터를 보지 않은 채 RUNNING → PAUSED → RUNNING 상태를 기록했습니다.

결과는 분명했습니다. 3개월 연속 손실 규칙은 6개 전략을 모두 멈췄고, 가장 긴 전략은 638거래일 동안 주문을 내지 못했습니다. 개발 구간 MDD의 1.5배를 중지선으로 사용하자 6개 중 ATR 돌파와 ROC 추세 두 전략만 멈췄습니다.

하지만 MDD 기준도 공짜 안전장치는 아니었습니다. ROC 추세의 MDD는 -23.94%에서 -22.66%로 1.28%p 줄었지만 누적수익률은 +42.44%에서 +9.27%로 낮아졌습니다. 중지 구간의 회복 수익을 실제 계좌가 따라가지 못했기 때문입니다.

SYSTEM LAB 14 핵심 결과

  • 3개월 연속 손실: 6개 전략 모두 중지
  • 최장 주문 중지: 돈치안 돌파 638거래일
  • 과거 MDD 1.5배: 6개 중 ATR·ROC 두 전략만 중지
  • ROC 추세: 수익률 +42.44% → +9.27%, MDD -23.94% → -22.66%
  • 중지 규칙은 수익률 개선법이 아니라 이상 상태를 제한하는 운영 규칙

1. 모의운용은 수익 가능성을 다시 증명하는 단계가 아니다

전진분석까지 끝낸 전략을 모의운용하는 목적은 좋은 수익률을 한 번 더 확인하는 것이 아닙니다. 신호가 의도한 시점에 발생하는지, 주문이 예상한 수량으로 나가는지, 체결과 잔고가 내부 상태와 일치하는지 확인하는 과정입니다.

그래서 자동매매 로그는 손익 숫자 하나로 끝나면 안 됩니다.

단계 반드시 남길 항목 확인할 문제
신호 전략 버전, 종목, 신호 시각, 기준 가격 백테스트와 같은 신호인가
주문 주문 ID, 방향, 수량, 요청 시각 중복·누락 주문이 있는가
체결 체결 시각, 가격, 수량, 수수료 지연과 슬리피지가 얼마인가
잔고 예상 수량, 실제 수량, 현금 내부 포지션과 계좌가 일치하는가
위험 상태 자산곡선, 낙폭, 운용 상태, 변경 사유 왜 멈추고 다시 시작했는가

이번 실험은 마지막 위험 상태를 다룹니다. 증권사 주문 API를 연결하는 대신 이미 검증한 전략 수익률을 하루씩 흘려보내며, 그날 종가까지 확인한 정보로 다음 거래일의 운용 상태를 결정합니다.

EMA 변수를 매년 다시 고른 전진분석이 전략 선택 과정의 표본외 검증이었다면, 이번 글은 선택을 끝낸 전략을 실제로 어떻게 감시할지 정하는 단계입니다.

2. 중지 기준은 손실이 생기기 전에 고정한다

비교한 운용 방식은 세 가지입니다.

운용 방식 중지 조건 재개 조건
계속 운용 없음 없음
3개월 연속 손실 월간 수익률이 3개월 연속 음수 가상 자산곡선이 중지 전 고점 회복
과거 MDD 1.5배 현재 낙폭이 개발 구간 MDD 절댓값의 1.5배 도달 가상 자산곡선이 중지 전 고점 회복

3개월 연속 손실은 손실 횟수만 보고 끄는 반응형 규칙입니다. 전략마다 평소 변동성이 다른데도 같은 세 달을 적용합니다.

MDD 기준은 각 전략의 개발 구간에서 경험한 낙폭을 기준으로 삼습니다. 개발 구간 MDD가 -10%라면 중지선은 -15%, -20%라면 -30%가 됩니다. 이번 글의 1.5배는 비교를 위한 후보값이지 모든 전략에 통하는 정답이 아닙니다.

중지 신호는 종가에 확정하고 다음 거래일부터 주문을 멈춥니다. 재개 신호도 종가에 확인한 뒤 다음 거래일부터 적용합니다. 중지·재개 신호가 난 날의 수익률까지 소급해 제거하지 않습니다.

3. 주문을 멈춰도 가상 자산곡선은 멈추면 안 된다

실제 주문을 중단하면 실제 계좌만 보고는 전략이 회복됐는지 알 수 없습니다. 따라서 같은 신호와 가격으로 주문했다고 가정한 가상 자산곡선을 계속 계산해야 합니다.

TEXT
RUNNING: 실제 주문과 가상 자산곡선을 함께 기록
PAUSED: 실제 주문은 중지하고 가상 자산곡선만 기록
RESUME: 가상 자산곡선이 중지 전 고점을 회복하면 다음 거래일부터 재개

여기서 전고점 회복은 전략이 완전히 정상화됐다는 보장이 아닙니다. 재개 조건을 수치로 고정해 손실 중에 기준을 바꾸지 않도록 만드는 장치입니다.

낮은 상관관계의 시스템을 조합한 13편에서 한 전략의 부진이 곧 고장을 뜻하지 않는다는 점을 확인했습니다. 서로 다르게 움직이도록 고른 전략을 최근 성과가 나쁘다는 이유로 바로 제거하면 포트폴리오를 구성한 목적도 사라집니다.

4. 실험 조건

앞선 연재와 연결하기 위해 같은 국내 주식 30종목과 여섯 전략을 사용했습니다.

구분 조건
가격 데이터 yfinance 수정주가 일봉
종목 KOSPI·KOSDAQ 유동성 종목 30개
전략 EMA 20·100, EMA 35·120, 돈치안, ATR, ROC, CCI
개발 구간 2014년부터 2021년까지
표본외 운용 구간 2022년부터 2025년까지
신호 확정 당일 종가
체결 다음 거래일 시가
거래비용 매수·매도 각각 0.05%
종목 배분 전략 안에서 30종목 동일 비중

개발 구간은 전략별 MDD를 계산하는 데만 사용했습니다. 중지 횟수와 수익률 비교는 남겨둔 표본외 구간에서 계산했습니다.

여섯 전략의 진입·청산 조건은 앞선 글과 같습니다. 특히 같은 돌파 진입에 네 가지 청산을 비교한 글처럼 신호일과 체결일을 분리하고 보유 중 중복 진입을 막았습니다.

5. 전체 Python 코드

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

TEXT
python -m pip install yfinance pandas numpy matplotlib pyarrow

아래 코드 하나가 가격을 내려받고, 여섯 전략의 개발·표본외 수익률을 만든 뒤 중지·재개 상태와 이벤트 로그, 요약표와 차트를 저장합니다.

PYTHON
#!/usr/bin/env python3
"""연속 손실과 과거 MDD 기준의 시스템 중지·재개 정책을 표본외 구간에서 비교한다."""

from __future__ import annotations

import time
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"]
DOWNLOAD_START = "2012-01-01"
TRAIN_START = "2014-01-01"
TRAIN_END = "2022-01-01"
TEST_START = "2022-01-01"
TEST_END = "2026-01-01"
COST_RATE = 0.0005
MDD_MULTIPLIER = 1.5
NEGATIVE_MONTH_LIMIT = 3
MIN_ROWS = 252
PRICE_CACHE_FILE = Path("system_stop_resume_prices.parquet")
CHART_FILE = Path("system_stop_resume_chart.png")
RESULT_FILE = Path("system_stop_resume_results.csv")
EVENT_FILE = Path("system_stop_resume_events.csv")

STRATEGIES = {
    "EMA 20·100": ("ema_20_100_entry", "ema_20_100_exit", None, None),
    "EMA 35·120": ("ema_35_120_entry", "ema_35_120_exit", None, None),
    "돈치안 돌파": ("donchian_entry", "channel_exit", 0.10, 120),
    "ATR 돌파": ("atr_entry", "channel_exit", 0.10, 120),
    "ROC 추세": ("roc_entry", "roc_exit", None, None),
    "CCI 역추세": ("cci_entry", "cci_exit", 0.08, 20),
}

def read_price_cache(path: Path) -> dict[str, pd.DataFrame]:
    cached = pd.read_parquet(path)
    cached["date"] = pd.to_datetime(cached["date"])
    return {ticker: group.set_index("date")[["open", "high", "low", "close", "volume"]].sort_index() for ticker, group in cached.groupby("ticker")}

def download_prices() -> dict[str, pd.DataFrame]:
    if PRICE_CACHE_FILE.is_file():
        return read_price_cache(PRICE_CACHE_FILE)

    raw = yf.download(TICKERS, start=DOWNLOAD_START, end=TEST_END, auto_adjust=True, progress=False, group_by="ticker", threads=False)
    if raw.empty:
        raise RuntimeError("가격 데이터를 받지 못했습니다.")
    prices = {}
    for ticker in TICKERS:
        frame = raw[ticker].rename(columns=str.lower).dropna(subset=["open", "high", "low", "close"]).copy()
        if len(frame.loc[frame.index < TEST_END]) < MIN_ROWS:
            for attempt in range(3):
                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)
                candidate = retry.rename(columns=str.lower).dropna(subset=["open", "high", "low", "close"]).copy()
                if len(candidate) > len(frame):
                    frame = candidate
                if len(frame.loc[frame.index < TEST_END]) >= MIN_ROWS:
                    break
                time.sleep(1.0 + attempt)
        if len(frame.loc[frame.index < TEST_END]) < MIN_ROWS:
            raise RuntimeError(f"검증에 필요한 가격 데이터가 부족합니다: {ticker}")
        prices[ticker] = frame
    PRICE_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
    cached = pd.concat([frame.reset_index(names="date").assign(ticker=ticker) for ticker, frame in prices.items()], ignore_index=True)
    cached.to_parquet(PRICE_CACHE_FILE, index=False)
    return prices

def mean_absolute_deviation(values: np.ndarray) -> float:
    return float(np.mean(np.abs(values - values.mean())))

def prepare_price(raw: pd.DataFrame) -> pd.DataFrame:
    data = raw.copy()
    for fast_window, slow_window in ((20, 100), (35, 120)):
        fast = data["close"].ewm(span=fast_window, adjust=False, min_periods=fast_window).mean()
        slow = data["close"].ewm(span=slow_window, adjust=False, min_periods=slow_window).mean()
        data[f"ema_{fast_window}_{slow_window}_entry"] = (fast > slow) & (fast.shift(1) <= slow.shift(1))
        data[f"ema_{fast_window}_{slow_window}_exit"] = (fast < slow) & (fast.shift(1) >= slow.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)
    atr = true_range.ewm(alpha=1.0 / 14, adjust=False, min_periods=14).mean()
    atr_center = data["close"].ewm(span=20, adjust=False, min_periods=20).mean()
    atr_upper = atr_center + 2.0 * atr
    donchian_upper = data["high"].shift(1).rolling(20, min_periods=20).max()
    common_exit = data["low"].shift(1).rolling(10, min_periods=10).min()
    data["donchian_entry"] = (data["close"] > donchian_upper) & (data["close"].shift(1) <= donchian_upper.shift(1))
    data["atr_entry"] = (data["close"] > atr_upper) & (data["close"].shift(1) <= atr_upper.shift(1))
    data["channel_exit"] = (data["close"] < common_exit) & (data["close"].shift(1) >= common_exit.shift(1))

    roc = data["close"].pct_change(20) * 100.0
    positive_episode = (roc <= 0.0).cumsum()
    roc_candidate = roc > 2.0
    previous_candidate = roc_candidate.groupby(positive_episode).cummax().groupby(positive_episode).shift(1, fill_value=False)
    data["roc_entry"] = roc_candidate & ~previous_candidate
    data["roc_exit"] = (roc < 0.0) & (roc.shift(1) >= 0.0)

    typical_price = (data["high"] + data["low"] + data["close"]) / 3.0
    typical_average = typical_price.rolling(20, min_periods=20).mean()
    mean_deviation = typical_price.rolling(20, min_periods=20).apply(mean_absolute_deviation, raw=True)
    cci = (typical_price - typical_average) / (0.015 * mean_deviation)
    ema_long = data["close"].ewm(span=200, adjust=False, min_periods=200).mean()
    trend_filter = (data["close"] > ema_long) & (ema_long > ema_long.shift(20))
    volatility_filter = atr / data["close"] * 100.0 <= 5.0
    data["cci_entry"] = (cci > -100.0) & (cci.shift(1) <= -100.0) & trend_filter & volatility_filter
    data["cci_exit"] = (cci > 100.0) & (cci.shift(1) <= 100.0)
    return data

def run_backtest(data: pd.DataFrame, entry_column: str, exit_column: str, stop_loss: float | None, maximum_holding_days: int | None) -> pd.DataFrame:
    cash = 1.0
    shares = 0.0
    pending_order = None
    entry_price = np.nan
    entry_index = -1
    daily_records = []
    for index, (date, row) in enumerate(data.iterrows()):
        if pending_order == "buy" and shares == 0.0:
            entry_price = float(row["open"])
            shares = cash * (1.0 - COST_RATE) / entry_price
            cash = 0.0
            entry_index = index
        elif pending_order == "sell" and shares > 0.0:
            cash = shares * float(row["open"]) * (1.0 - COST_RATE)
            shares = 0.0
            entry_price = np.nan
            entry_index = -1
        pending_order = None

        if shares > 0.0:
            holding_days = index - entry_index
            close_return = float(row["close"]) / entry_price - 1.0
            if stop_loss is not None and close_return <= -stop_loss:
                pending_order = "sell"
            elif bool(row[exit_column]):
                pending_order = "sell"
            elif maximum_holding_days is not None and holding_days >= maximum_holding_days:
                pending_order = "sell"
        elif bool(row[entry_column]):
            pending_order = "buy"
        daily_records.append({"date": date, "equity": cash + shares * float(row["close"])})

    if shares > 0.0:
        cash = shares * float(data.iloc[-1]["close"]) * (1.0 - COST_RATE)
        daily_records[-1]["equity"] = cash
    return pd.DataFrame(daily_records).set_index("date")

def build_strategy_returns(prepared: dict[str, pd.DataFrame], start: str, end: str) -> pd.DataFrame:
    calendar = pd.DatetimeIndex(sorted(set().union(*(data.loc[(data.index >= start) & (data.index < end)].index for data in prepared.values()))))
    strategy_returns = {}
    for label, (entry_column, exit_column, stop_loss, maximum_holding_days) in STRATEGIES.items():
        equity_columns = []
        for ticker, full_data in prepared.items():
            data = full_data.loc[(full_data.index >= start) & (full_data.index < end)].copy()
            daily = run_backtest(data, entry_column, exit_column, stop_loss, maximum_holding_days)
            equity_columns.append(daily["equity"].reindex(calendar).ffill().fillna(1.0).rename(ticker))
        portfolio_equity = pd.concat(equity_columns, axis=1).mean(axis=1)
        strategy_returns[label] = portfolio_equity.pct_change().fillna(0.0)
    return pd.DataFrame(strategy_returns)

def summarize_equity(equity: pd.Series) -> dict[str, float]:
    drawdown = equity / equity.cummax() - 1.0
    return {"return": float(equity.iloc[-1] - 1.0), "mdd": float(drawdown.min())}

def apply_monitoring_policy(returns: pd.Series, policy: str, drawdown_limit: float | None = None) -> tuple[pd.DataFrame, pd.DataFrame]:
    shadow_equity = (1.0 + returns).cumprod()
    shadow_peak = shadow_equity.cummax()
    shadow_drawdown = shadow_equity / shadow_peak - 1.0
    monthly_returns = (1.0 + returns).groupby(returns.index.to_period("M")).prod() - 1.0
    running = True
    resume_level = np.nan
    negative_months = 0
    active_equity = 1.0
    records = []
    events = []

    for index, (date, daily_return) in enumerate(returns.items()):
        state_at_open = "RUNNING" if running else "PAUSED"
        if running:
            active_equity *= 1.0 + daily_return
        records.append({"date": date, "policy": policy, "shadow_equity": shadow_equity.iloc[index], "active_equity": active_equity, "shadow_drawdown": shadow_drawdown.iloc[index], "state": state_at_open})

        month_end = index == len(returns) - 1 or returns.index[index + 1].to_period("M") != date.to_period("M")
        if not running:
            if shadow_equity.iloc[index] >= resume_level:
                running = True
                negative_months = 0
                events.append({"date": date, "policy": policy, "event": "RESUME", "reason": "가상 자산곡선 전고점 회복", "shadow_equity": shadow_equity.iloc[index], "shadow_drawdown": shadow_drawdown.iloc[index]})
            continue

        should_pause = False
        reason = ""
        if policy == "과거 MDD 1.5배" and drawdown_limit is not None and shadow_drawdown.iloc[index] <= -drawdown_limit:
            should_pause = True
            reason = f"낙폭 {shadow_drawdown.iloc[index]:.2%}가 중지선 {-drawdown_limit:.2%} 하회"
        elif policy == "3개월 연속 손실" and month_end:
            negative_months = negative_months + 1 if monthly_returns.loc[date.to_period("M")] < 0.0 else 0
            if negative_months >= NEGATIVE_MONTH_LIMIT:
                should_pause = True
                reason = "월간 수익률 3개월 연속 음수"

        if should_pause:
            running = False
            negative_months = 0
            resume_level = shadow_peak.iloc[index]
            events.append({"date": date, "policy": policy, "event": "PAUSE", "reason": reason, "shadow_equity": shadow_equity.iloc[index], "shadow_drawdown": shadow_drawdown.iloc[index]})

    return pd.DataFrame(records).set_index("date"), pd.DataFrame(events)

def build_results(train_returns: pd.DataFrame, test_returns: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    result_rows = []
    event_frames = []
    daily_frames = []
    for strategy in STRATEGIES:
        train_equity = (1.0 + train_returns[strategy]).cumprod()
        train_mdd = abs(summarize_equity(train_equity)["mdd"])
        drawdown_limit = train_mdd * MDD_MULTIPLIER
        always_equity = (1.0 + test_returns[strategy]).cumprod()
        always_metrics = summarize_equity(always_equity)
        result_rows.append({"strategy": strategy, "policy": "계속 운용", "train_mdd": -train_mdd, "stop_line": -drawdown_limit, "return": always_metrics["return"], "mdd": always_metrics["mdd"], "pause_count": 0, "paused_days": 0})

        for policy in ("3개월 연속 손실", "과거 MDD 1.5배"):
            daily, events = apply_monitoring_policy(test_returns[strategy], policy, drawdown_limit)
            metrics = summarize_equity(daily["active_equity"])
            pause_count = int((events["event"] == "PAUSE").sum()) if not events.empty else 0
            paused_days = int((daily["state"] == "PAUSED").sum())
            result_rows.append({"strategy": strategy, "policy": policy, "train_mdd": -train_mdd, "stop_line": -drawdown_limit, "return": metrics["return"], "mdd": metrics["mdd"], "pause_count": pause_count, "paused_days": paused_days})
            daily_frames.append(daily.reset_index().assign(strategy=strategy))
            if not events.empty:
                event_frames.append(events.assign(strategy=strategy))

    results = pd.DataFrame(result_rows)
    events = pd.concat(event_frames, ignore_index=True) if event_frames else pd.DataFrame()
    daily_records = pd.concat(daily_frames, ignore_index=True)
    return results, events, daily_records

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(results: pd.DataFrame, events: pd.DataFrame, daily_records: pd.DataFrame) -> None:
    colors = {"계속 운용": "#64748B", "3개월 연속 손실": "#F59E0B", "과거 MDD 1.5배": "#0F766E"}
    fig = plt.figure(figsize=(14, 11), facecolor="white")
    grid = fig.add_gridspec(2, 2, height_ratios=[1.35, 1.0], hspace=0.34, wspace=0.27)
    equity_axis = fig.add_subplot(grid[0, :])
    ratio_axis = fig.add_subplot(grid[1, 0])
    return_axis = fig.add_subplot(grid[1, 1])

    strategy = "ROC 추세"
    policies = ["계속 운용", "3개월 연속 손실", "과거 MDD 1.5배"]
    roc_results = results.loc[results["strategy"] == strategy].set_index("policy").loc[policies]
    always = daily_records.loc[(daily_records["strategy"] == strategy) & (daily_records["policy"] == "과거 MDD 1.5배")].set_index("date")
    loss_policy = daily_records.loc[(daily_records["strategy"] == strategy) & (daily_records["policy"] == "3개월 연속 손실")].set_index("date")
    equity_axis.plot(always.index, always["shadow_equity"], color=colors["계속 운용"], linewidth=2.1, label=f"계속 운용 {roc_results.loc['계속 운용', 'return']:+.1%}")
    equity_axis.plot(loss_policy.index, loss_policy["active_equity"], color=colors["3개월 연속 손실"], linewidth=2.0, label=f"3개월 연속 손실 {roc_results.loc['3개월 연속 손실', 'return']:+.1%}")
    equity_axis.plot(always.index, always["active_equity"], color=colors["과거 MDD 1.5배"], linewidth=2.5, label=f"과거 MDD 1.5배 {roc_results.loc['과거 MDD 1.5배', 'return']:+.1%}")
    paused = always["state"].eq("PAUSED")
    equity_axis.fill_between(always.index, 0.85, 1.55, where=paused, color="#CCFBF1", alpha=0.8, step="post", label="MDD 기준 주문 중지")
    roc_events = events.loc[(events["strategy"] == strategy) & (events["policy"] == "과거 MDD 1.5배")]
    for _, event in roc_events.iterrows():
        marker = "v" if event["event"] == "PAUSE" else "^"
        color = "#B91C1C" if event["event"] == "PAUSE" else "#047857"
        label = "중지" if event["event"] == "PAUSE" else "재개"
        equity_axis.scatter(event["date"], event["shadow_equity"], marker=marker, s=90, color=color, zorder=5)
        equity_axis.annotate(label, (event["date"], event["shadow_equity"]), xytext=(0, 12 if marker == "^" else -20), textcoords="offset points", ha="center", color=color, fontweight="bold")
    equity_axis.set_ylim(0.85, 1.55)
    equity_axis.set_ylabel("표본외 누적 자산")
    equity_axis.set_title("중지 기간에도 가상 자산곡선을 계산해야 재개 시점을 정할 수 있다", fontsize=17, fontweight="bold", pad=14)
    equity_axis.legend(loc="upper left", frameon=False, ncol=2)

    always_rows = results.loc[results["policy"] == "계속 운용"].set_index("strategy")
    mdd_rows = results.loc[results["policy"] == "과거 MDD 1.5배"].set_index("strategy")
    ratios = always_rows["mdd"].abs() / always_rows["train_mdd"].abs()
    bar_colors = ["#0F766E" if mdd_rows.loc[label, "pause_count"] else "#CBD5E1" for label in ratios.index]
    ratio_axis.barh(ratios.index, ratios.values, color=bar_colors)
    ratio_axis.axvline(MDD_MULTIPLIER, color="#B91C1C", linestyle="--", linewidth=1.8, label="중지선 1.5배")
    ratio_axis.set_xlabel("표본외 MDD ÷ 개발 구간 MDD")
    ratio_axis.set_title("1.5배 중지선은 6개 중 2개만 작동", fontsize=14, fontweight="bold")
    ratio_axis.legend(frameon=False, loc="lower right")
    for index, value in enumerate(ratios.values):
        ratio_axis.text(value + 0.03, index, f"{value:.2f}배", va="center", fontsize=9)

    bars = return_axis.bar(np.arange(len(policies)), roc_results["return"] * 100.0, color=[colors[policy] for policy in policies])
    return_axis.set_xticks(np.arange(len(policies)), ["계속 운용", "3개월 손실", "MDD 1.5배"])
    return_axis.set_ylabel("누적수익률(%)")
    return_axis.set_title("ROC 추세의 중지 비용", fontsize=14, fontweight="bold")
    return_axis.bar_label(bars, labels=[f"{value:.1f}%" for value in roc_results["return"] * 100.0], padding=4, fontsize=10, fontweight="bold")
    return_axis.set_ylim(0.0, max(50.0, float((roc_results["return"] * 100.0).max() * 1.22)))

    for axis in (equity_axis, ratio_axis, return_axis):
        axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
        axis.spines["top"].set_visible(False)
        axis.spines["right"].set_visible(False)
    fig.suptitle("연속 손실만으로 끄면 과잉 중단, MDD 기준도 회복 수익을 포기했다", fontsize=21, fontweight="bold", y=0.99)
    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()
prepared = {ticker: prepare_price(frame) for ticker, frame in prices.items()}
train_returns = build_strategy_returns(prepared, TRAIN_START, TRAIN_END)
test_returns = build_strategy_returns(prepared, TEST_START, TEST_END)
results, events, daily_records = build_results(train_returns, test_returns)
RESULT_FILE.parent.mkdir(parents=True, exist_ok=True)
results.to_csv(RESULT_FILE, index=False, encoding="utf-8-sig")
events.to_csv(EVENT_FILE, index=False, encoding="utf-8-sig")
draw_chart(results, events, daily_records)

summary = results.pivot(index="strategy", columns="policy", values=["return", "mdd", "pause_count", "paused_days"])
print(summary.to_string(float_format=lambda value: f"{value:.4f}"))
print(events.to_string(index=False, formatters={"shadow_equity": "{:.4f}".format, "shadow_drawdown": "{:.2%}".format}))
print(f"결과 저장: {RESULT_FILE}")
print(f"이벤트 로그 저장: {EVENT_FILE}")
print(f"차트 저장: {CHART_FILE}")

apply_monitoring_policy()는 하루 수익률을 반영한 다음 그날 종가 기준으로 중지와 재개를 판단합니다. 결정은 다음 거래일부터 적용되므로 미래 수익률을 미리 제외하지 않습니다.

이벤트 로그에는 PAUSERESUME이 발생한 날짜, 이유, 당시 가상 자산과 낙폭을 남깁니다. 코드가 멈춘 이유를 나중에 사람의 기억으로 복원하지 않아도 됩니다.

6. 3개월 손실 기준은 6개 전략을 모두 멈췄다

표본외 구간에서 여섯 전략 모두 월간 수익률이 세 달 연속 음수가 되는 구간을 만났습니다.

전략 계속 운용 수익률 3개월 손실 적용 계속 운용 MDD 적용 후 MDD 중지 횟수 중지 거래일
EMA 20·100 +83.26% +21.80% -21.18% -26.47% 3회 509일
EMA 35·120 +79.97% +17.51% -22.57% -29.01% 3회 510일
돈치안 돌파 +28.85% +8.30% -13.96% -8.98% 2회 638일
ATR 돌파 +31.19% +14.54% -15.77% -8.28% 2회 632일
ROC 추세 +42.44% +11.41% -23.94% -14.00% 2회 633일
CCI 역추세 +7.20% +0.06% -5.96% -6.66% 2회 625일

연속 손실 규칙은 일부 전략의 MDD를 줄였지만 모든 전략의 수익률을 낮췄습니다. 돈치안·ATR·ROC는 낙폭이 줄어든 대신 600거래일 이상 주문이 멈췄습니다.

더 중요한 반례는 두 EMA 전략입니다. EMA 20·100의 수익률은 +83.26%에서 +21.80%로 낮아졌고 MDD는 -21.18%에서 -26.47%로 오히려 깊어졌습니다. 회복 구간을 건너뛴 뒤 실제 자산곡선이 전고점을 되찾지 못한 상태에서 다음 손실을 맞았기 때문입니다.

손실 횟수만으로 끄는 규칙은 전략의 평소 위험 범위를 보지 않습니다. 세 달 손실이 이상 상태가 아니라 흔한 변동일 수도 있습니다.

7. MDD 1.5배 기준은 두 전략만 골라냈다

개발 구간 MDD에 1.5를 곱하자 중지선은 전략마다 달라졌습니다. 표본외 MDD가 그 선을 넘은 전략은 ATR 돌파와 ROC 추세 두 개였습니다.

전략 개발 MDD 1.5배 중지선 계속 운용 수익률 적용 후 수익률 계속 운용 MDD 적용 후 MDD 중지 거래일
ATR 돌파 -8.83% -13.25% +31.19% +13.78% -15.77% -13.25% 143일
ROC 추세 -14.57% -21.85% +42.44% +9.27% -23.94% -22.66% 129일
연속 손실과 과거 MDD 중지 규칙의 표본외 성과 및 중지 구간

MDD 기준은 연속 손실 규칙보다 선택적으로 작동했습니다. EMA 두 전략은 표본외 낙폭이 개발 구간과 비슷했고, 돈치안과 CCI는 더 얕았기 때문에 중단하지 않았습니다.

그렇다고 1.5배 기준이 좋은 수익률을 만든 것은 아닙니다. ROC 추세는 중지된 뒤 가상 자산곡선이 전고점을 회복하는 과정에서 큰 반등이 나왔습니다. 실제 주문은 멈춰 있었기 때문에 재개 시점에는 가상 계좌와 실제 계좌 사이에 큰 차이가 생겼습니다.

낙폭은 1.28%p 줄었지만 수익률은 33.17%p 낮아졌습니다. 손실을 제한하려고 만든 규칙이 회복 수익까지 포기하게 한 사례입니다.

8. 중지선의 1.5배는 정답이 아니다

과거 MDD는 한 번 관찰된 최악의 값입니다. 표본 기간이 짧거나 우연히 순조로웠다면 위험을 과소평가할 수 있습니다. 반대로 과거에 큰 충격이 한 번 포함됐다면 중지선이 너무 멀어져 실제로는 작동하지 않을 수 있습니다.

이번 데이터에서도 1.5배 기준은 6개 중 2개에만 작동했고, 2배로 넓히면 여섯 전략 모두 중지되지 않았습니다. 배수를 바꾸는 순간 중지 횟수와 놓치는 수익이 크게 달라집니다.

따라서 실제 운용 전에는 한 값만 보지 말고 다음을 함께 확인해야 합니다.

  • 과거 MDD의 1.0배·1.25배·1.5배·2.0배에서 중지 횟수가 어떻게 달라지는가
  • 중지 시점 이후 가상 자산곡선이 전고점을 회복하는 데 얼마나 걸렸는가
  • 중지로 줄인 손실보다 놓친 수익이 얼마나 큰가
  • 전략 하나의 중지와 전체 포트폴리오 중지 기준을 분리했는가

기준을 여러 번 비교하는 목적은 가장 수익률이 높은 배수를 고르는 것이 아닙니다. 작은 변화에도 운용 상태가 계속 뒤집히는지 확인하는 것입니다.

9. 실전에서는 전략 이상과 시스템 장애를 분리한다

낙폭이 커지는 것과 주문 시스템이 고장 나는 것은 다른 문제입니다.

구분 예시 대응
전략 위험 과거 범위를 넘는 낙폭, 승률·손익비 변화 신규 진입 중지, 가상 운용 지속, 재검증
실행 장애 주문 거부, 중복 주문, 체결 누락 즉시 주문 차단, 잔고 대조, 장애 원인 제거
데이터 장애 가격 누락, 비정상 값, 장중 시각 지연 신호 계산 중지, 원천 데이터 검증
계좌 위험 주문 가능 금액 부족, 포지션 한도 초과 계좌 단위 안전장치 실행

실행 장애는 과거 MDD까지 기다리면 안 됩니다. 주문·잔고 불일치처럼 계좌 상태를 신뢰할 수 없는 상황은 즉시 멈춰야 합니다. 반면 전략 손실은 미리 정한 통계 기준과 포트폴리오 역할을 함께 보고 판단해야 합니다.

10. 이번 실험의 한계

첫째, 현재 확인 가능한 유동성 종목 30개를 과거에도 그대로 사용해 상장폐지 종목이 빠진 생존 편향이 있습니다.

둘째, yfinance 수정주가 일봉과 가정 비용을 사용했습니다. 실제 주문 거부, 부분 체결, 호가 스프레드, 시장 충격과 세금은 모두 재현하지 못했습니다.

셋째, 중지된 동안 현금 수익률을 0%로 두었습니다. 실제 계좌의 예수금 이자나 다른 전략으로 자금을 이동하는 효과는 반영하지 않았습니다.

넷째, 재개 조건을 가상 자산곡선의 전고점 회복 하나로 고정했습니다. 일정 관찰 기간, 최근 거래 분포 회복, 전략 재승인 같은 조건을 추가하면 결과가 달라집니다.

다섯째, 3개월과 MDD 1.5배는 원리를 비교하기 위한 후보값입니다. 이번 결과만으로 특정 배수를 실전 표준으로 사용할 수는 없습니다.

11. 결론

첫째, 최근 손실만 보고 자동매매를 끄면 정상적인 변동까지 고장으로 오해할 수 있습니다. 이번 표본에서는 3개월 손실 규칙이 6개 전략을 모두 멈췄고 두 EMA 전략의 MDD는 오히려 깊어졌습니다.

둘째, 과거 MDD를 이용한 기준은 더 선택적으로 작동했지만 수익률을 개선하지는 않았습니다. ROC 추세는 MDD를 1.28%p 줄이는 대신 수익률 33.17%p를 포기했습니다.

셋째, 주문을 멈춘 뒤에도 가상 자산곡선과 이벤트 로그는 계속 기록해야 합니다. 그래야 중지와 재개를 감정이 아니라 같은 규칙으로 설명할 수 있습니다.

시스템 트레이딩의 마지막 규칙은 매수와 매도가 아닙니다. 시스템을 언제 믿고, 언제 멈추고, 어떤 증거가 쌓였을 때 다시 실행할지를 손실이 오기 전에 정하는 것입니다.

이 글은 학습과 정보 제공을 위한 자료이며 특정 종목이나 전략의 매수·매도를 권유하지 않습니다. 데이터와 가정을 바꾸면 결과도 달라질 수 있으며 최종 투자 판단과 책임은 투자자 본인에게 있습니다.