감으로 찾던 눌림목, 파이썬 자동매매 조건식으로 바꾸는 법

“상승 추세에서 거래량을 동반한 눌림목을 매수한다.”

사람이 들으면 대략적인 차트 모습이 떠오르는 문장입니다. 하지만 컴퓨터는 이 문장을 이해하지 못합니다. 상승 추세가 무엇인지, 얼마나 내려와야 눌림목인지, 거래량이 어느 정도 늘어야 하는지 숫자로 정해져 있지 않기 때문입니다.

시스템 전략을 개발하려면 먼저 매매 아이디어를 구체적이고 일관되며 계량 가능한 규칙으로 바꿔야 합니다. 이 연재에서는 사람이 사용하는 매매 문장을 Python이 판단할 수 있는 조건으로 하나씩 변환합니다.

이번 글의 목적은 수익률이 높은 전략을 찾는 것이 아닙니다. 모호한 매매 문장을 같은 데이터에서 누구나 같은 결과를 얻는 진입 후보 신호로 바꾸는 것이 목적입니다.

이번 글의 핵심 결론

  • 매매 아이디어는 입력 데이터, 조건, 행동, 예외로 나눠야 코드가 됩니다.
  • 이동평균선과 거래량의 계산 시점을 명시해야 미래 데이터를 섞지 않을 수 있습니다.
  • 재현 가능한 신호를 만들었다는 사실과 수익성 있는 전략을 만들었다는 사실은 다릅니다.

1. 매매 문장을 그대로 코드로 옮기면 안 되는 이유

“좋아 보이면 매수한다”는 규칙은 시스템이 아닙니다. 어제는 매수했던 차트를 비슷한 상황의 다음 날에는 매수하지 않을 수 있기 때문입니다. 판단자의 기분과 시장 분위기에 따라 결과가 달라지면 백테스트도 할 수 없습니다.

시스템 트레이딩에서 규칙은 최소한 다음 질문에 답할 수 있어야 합니다.

  1. 어떤 데이터를 사용합니까?
  2. 조건을 계산할 수식과 기간은 무엇입니까?
  3. 여러 조건은 AND입니까, OR입니까?
  4. 신호는 언제 확정됩니까?
  5. 신호가 나오면 어느 가격에 주문합니까?
  6. 포지션을 언제 청산하고 언제 다시 진입합니까?

기존의 시스템 트레이딩 전략은 어떻게 만들까? 아이디어부터 검증까지 6단계이 전체 개발 순서를 다뤘다면, 이번 글은 그중 전략 수립과 코드 작성 사이를 자세히 연결합니다. 자동매매 아이디어는 어디서 찾을까?에서 살펴본 단순한 아이디어도 아래와 같은 규칙표가 있어야 검증할 수 있습니다.

2. 눌림목 아이디어를 네 가지 조건으로 나누기

예제로 사용할 문장은 다음과 같습니다.

상승 추세에서 주가가 20일 이동평균선을 회복하고 거래량이 증가하면 진입 후보로 분류한다.

여전히 상승 추세, 회복, 거래량 증가가 모호합니다. 이번 실습에서는 다음처럼 정의합니다.

구성 요소 객관화한 규칙 선택한 이유
대상 데이터 삼성전자 005930.KS 수정 일봉 장기간 거래된 국내 대형주의 예시
상승 추세 60일선이 120일선보다 높고 종가가 120일선보다 높음 중기와 장기 흐름이 같은 방향인지 확인
눌림 후 회복 전일 종가는 전일 20일선 이하, 당일 종가는 당일 20일선 초과 단순히 20일선 위에 있는 상태가 아니라 상향 회복한 시점만 선택
거래량 증가 당일 거래량이 직전 20거래일 평균의 1.2배 이상 당일 거래량을 평균에서 제외해 비교 시점 고정
최종 후보 상승 추세 AND 20일선 회복 AND 거래량 증가 세 조건이 같은 거래일에 모두 참일 때만 표시
신호 확정 당일 장 마감 후 종가와 당일 거래량이 모두 확정되는 시점
주문 시점 이번 글에서는 주문하지 않음 후보 신호와 실제 체결을 먼저 분리

1.2배와 20·60·120일은 정답이 아닙니다. 아이디어를 코드로 표현하기 위해 선택한 출발값입니다. 이 숫자를 바꿔 가장 높은 과거 수익률을 찾는 작업은 신호 정의가 아니라 최적화에 해당합니다.

특히 거래량 평균은 shift(1)로 한 거래일 미룹니다. 당일 거래량을 당일의 20일 평균에 포함한 뒤 다시 당일 거래량과 비교하면 비교 기준에도 관측 대상이 섞입니다. 미래 참조는 아니지만 규칙의 의미가 흐려집니다. 여기서는 장이 열리기 전에 이미 알고 있던 직전 20거래일 평균과 당일 거래량을 비교합니다.

3. yfinance 데이터를 사용할 때 고정할 항목

독자가 별도 주식 DB를 만들지 않아도 실행할 수 있도록 yfinance를 사용합니다. yfinance의 download 공식 문서에 따르면 start는 포함하고 end는 포함하지 않습니다. 따라서 코드의 종료일을 2026-01-01로 지정하면 2025년의 마지막 거래일까지 요청합니다.

auto_adjust=True는 OHLC 가격에 주식분할과 배당 조정을 자동 반영합니다. multi_level_index=False는 단일 종목의 열을 Close, High, Low, Open, Volume처럼 한 단계로 받아 코드의 가독성을 높입니다.

yfinance는 연구와 개인 학습에 편리하지만 증권사 체결 데이터와 같지 않습니다. 데이터 공급원의 과거 가격 정정과 조정 방식에 따라 실행 시점이 달라지면 일부 숫자도 달라질 수 있습니다. 실제 주문 시스템에서는 증권사 데이터와 신호가 일치하는지 별도로 확인해야 합니다.

4. 전체 Python 코드

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

TEXT
python -m pip install yfinance pandas matplotlib

아래 코드는 import, 데이터 수집, 조건 계산, 결과 출력과 차트 저장까지 한 번에 실행됩니다.

PYTHON
from pathlib import Path

import matplotlib.pyplot as plt
import yfinance as yf
from matplotlib import font_manager


TICKER = "005930.KS"
START_DATE = "2018-01-01"
END_DATE = "2026-01-01"
CHART_START_DATE = "2024-01-01"
VOLUME_MULTIPLIER = 1.2
OUTPUT_FILE = Path("rules_chart.png")


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["trend_condition"] = (price["sma_60"] > price["sma_120"]) & (price["close"] > price["sma_120"])
price["recovery_condition"] = (price["close"] > price["sma_20"]) & (price["close"].shift(1) <= price["sma_20"].shift(1))
price["volume_condition"] = price["volume"] >= price["volume_average_20"] * VOLUME_MULTIPLIER
price["entry_signal"] = price["trend_condition"] & price["recovery_condition"] & price["volume_condition"]
price["volume_ratio"] = price["volume"] / price["volume_average_20"]

signal_table = price.loc[price["entry_signal"], ["close", "sma_20", "sma_60", "sma_120", "volume_ratio"]].copy()
signal_table.columns = ["종가", "20일선", "60일선", "120일선", "거래량배수"]
print(f"전체 거래일: {len(price):,}일")
print(f"상승 추세 조건: {int(price['trend_condition'].sum()):,}일")
print(f"20일선 회복 조건: {int(price['recovery_condition'].sum()):,}일")
print(f"거래량 조건: {int(price['volume_condition'].sum()):,}일")
print(f"최종 진입 후보: {int(price['entry_signal'].sum()):,}회")
print(signal_table.round(2).tail(10).to_string())

chart_data = price.loc[CHART_START_DATE:].copy()
chart_signals = chart_data.loc[chart_data["entry_signal"]]
fig, (price_axis, volume_axis) = plt.subplots(2, 1, figsize=(12, 8), sharex=True, gridspec_kw={"height_ratios": [3, 1]}, facecolor="white")
price_axis.plot(chart_data.index, chart_data["close"], color="#111827", linewidth=1.8, label="수정 종가")
price_axis.plot(chart_data.index, chart_data["sma_20"], color="#F59E0B", linewidth=1.4, label="20일선")
price_axis.plot(chart_data.index, chart_data["sma_60"], color="#0F766E", linewidth=1.4, label="60일선")
price_axis.plot(chart_data.index, chart_data["sma_120"], color="#64748B", linewidth=1.4, label="120일선")
price_axis.scatter(chart_signals.index, chart_signals["close"], color="#DC2626", edgecolor="white", linewidth=0.8, marker="^", s=85, zorder=5, label="진입 후보")
price_axis.set_title("삼성전자 눌림목 후보 신호 객관화", fontsize=17, fontweight="bold", pad=16)
price_axis.set_ylabel("가격")
price_axis.legend(loc="upper left", ncol=5, frameon=False)
price_axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
price_axis.yaxis.tick_right()
price_axis.yaxis.set_label_position("right")

volume_colors = ["#F59E0B" if value >= VOLUME_MULTIPLIER else "#CBD5E1" for value in chart_data["volume_ratio"].fillna(0.0)]
volume_axis.bar(chart_data.index, chart_data["volume_ratio"], color=volume_colors, width=2.0)
volume_axis.axhline(VOLUME_MULTIPLIER, color="#DC2626", linewidth=1.2, linestyle="--", label="과거 20일 평균의 1.2배")
volume_axis.set_ylabel("거래량 배수")
volume_axis.legend(loc="upper left", frameon=False)
volume_axis.grid(axis="y", color="#E5E7EB", linewidth=0.8)
volume_axis.yaxis.tick_right()
volume_axis.yaxis.set_label_position("right")

for axis in (price_axis, volume_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(OUTPUT_FILE, dpi=160, bbox_inches="tight", facecolor="white")
plt.show()

코드에서 가장 중요한 부분은 마지막 차트가 아니라 세 개의 Boolean 조건입니다.

TEXT
trend_condition AND recovery_condition AND volume_condition

각 열에는 거래일마다 True 또는 False가 저장됩니다. 세 열이 모두 True인 날만 entry_signal이 됩니다. 복잡한 점수나 숨겨진 판단이 없으므로 특정 날짜가 왜 후보가 됐는지 각 열을 확인해 설명할 수 있습니다.

5. 조건을 겹치면 1,960거래일이 19회로 줄어든다

작성 환경에서 코드를 실행한 결과는 다음과 같습니다.

구분 조건을 만족한 거래일
전체 데이터 1,960일
상승 추세 조건 900일
20일선 상향 회복 122일
거래량 1.2배 이상 473일
세 조건의 교집합 19회

900일, 122일, 473일은 순서대로 걸러진 개수가 아니라 각 조건을 독립적으로 센 숫자입니다. 최종 19회는 세 조건이 같은 날 겹친 교집합입니다.

삼성전자 눌림목 진입 후보 신호와 거래량 조건

빨간 삼각형은 매수 체결점이 아니라 진입 검토 후보입니다. 2024년 상반기에는 장기 상승 조건 안에서 20일선을 회복할 때 여러 후보가 나타났지만, 이후 가격이 계속 오른 경우도 있고 하락으로 이어진 경우도 있습니다. 조건을 객관화했다고 실패 신호가 사라지는 것은 아닙니다.

2025년 5월에는 비슷한 가격대에서 후보가 두 번 나타났습니다. 시스템이 포지션 상태를 관리하지 않으면 보유 중에도 신호를 반복해서 낼 수 있다는 뜻입니다. 후보 신호와 실제 주문을 분리한 이유가 여기에 있습니다. 다음 단계에서는 미보유 상태에서만 진입하고, 보유 중에는 추가 신호를 무시하는 상태 관리가 필요합니다.

6. 신호를 만들 때 자주 생기는 다섯 가지 오류

6.1 상승 추세를 눈으로 판단한다

코드 밖에서 차트를 보고 상승 추세 종목만 골랐다면 백테스트에는 사람의 사후 판단이 섞입니다. 종목 선정 조건도 이동평균선이나 수익률처럼 계산 가능한 규칙으로 넣어야 합니다.

6.2 20일선 위의 모든 날을 회복 신호로 처리한다

close > sma_20만 사용하면 주가가 20일선 위에 머무는 동안 매일 신호가 발생합니다. 전일에는 아래에 있었고 당일에 위로 올라왔다는 교차 조건을 함께 써야 한 번의 회복 시점을 찾을 수 있습니다.

6.3 당일 거래량을 비교 평균에 섞는다

당일 거래량을 포함한 20일 평균과 당일 거래량을 비교하면 기준선이 당일 값에 끌려 올라갑니다. 이 글에서는 volume.shift(1).rolling(20).mean()으로 직전 20거래일만 사용합니다.

6.4 신호를 곧바로 체결로 간주한다

종가와 거래량을 사용하는 신호는 장 마감 후에야 확정됩니다. 같은 날 종가에 이미 매수했다고 처리하면 실제로 불가능한 체결이 됩니다. 백테스트에서 흔히 빠지는 3가지 함정에서 설명한 미래 참조 오류로 이어질 수 있습니다.

6.5 후보 개수가 적다는 이유로 조건을 계속 바꾼다

19회가 적어 보인다고 1.2배를 1.17배로 바꾸고 이동평균 기간을 반복 조정하면 과거 데이터에 맞춘 규칙이 됩니다. 변수 선택에는 시장 구조에 관한 이유가 먼저 있어야 하며, 최적화는 표본외 데이터와 함께 별도 단계에서 진행해야 합니다.

7. 진입 후보를 주문으로 바꾸기 전에 필요한 규칙

이번 코드에는 다음 항목이 없습니다.

  • 다음 거래일 시가 체결
  • 보유 중 중복 진입 방지
  • 손절과 이익 실현
  • 최대 보유 기간
  • 청산 후 재진입 대기 기간
  • 수수료, 세금, 슬리피지

따라서 19회라는 숫자로 전략의 수익성을 판단할 수 없습니다. 지금 완성된 것은 언제 관심을 가질 것인가라는 후보 규칙뿐입니다. 자동매매 진입·청산·필터는 어떻게 나눌까?에서 다룬 청산 조건과 체결 순서가 추가돼야 비로소 백테스트할 수 있는 전략이 됩니다.

신호를 주문으로 바꾸기 전에는 다음 문장을 모두 숫자로 채워야 합니다.

TEXT
신호는 t일 장 마감 후 확정한다.
포지션이 없을 때만 t+1일 시가에 진입한다.
진입가에서 정한 손실 한도에 도달하면 청산한다.
최대 보유 기간을 넘으면 청산한다.
청산 후 정한 거래일 동안 재진입하지 않는다.

이 규칙은 다음 글에서 포지션 상태를 가진 Python 백테스트 엔진으로 구현합니다.

8. 마치며: 좋은 아이디어보다 먼저 필요한 것

이번 실습에서 확인한 내용은 세 가지입니다.

  1. 모호한 매매 아이디어는 데이터, 조건, 행동과 시점으로 나눠야 코드가 됩니다.
  2. 각 조건을 Boolean 열로 만들면 특정 거래일의 신호 발생 이유를 다시 설명할 수 있습니다.
  3. 재현 가능한 후보 신호는 전략 개발의 시작이며 수익성을 증명한 결과가 아닙니다.

시스템 트레이딩은 사람의 판단을 없애는 작업이 아니라 판단의 내용을 거래 전에 기록하는 작업에 가깝습니다. 문장으로 설명할 수 없는 조건은 코드로도 안정적으로 구현하기 어렵습니다.

다음 시스템 실전 02에서는 이번 entry_signal을 이어받아 미보유, 보유, 청산 대기 상태를 구분합니다. 당일 종가 신호를 다음 거래일 시가에 체결하고, 중복 진입과 미래 참조를 막는 최소 백테스트 엔진을 만들 예정입니다.


이 글은 시스템 트레이딩 규칙을 설명하기 위한 교육용 자료입니다. 예시 신호는 특정 종목의 매수 추천이나 미래 수익을 의미하지 않으며, 실제 투자 판단과 책임은 투자자 본인에게 있습니다.