AI 자동매매에서 종목을 어떻게 고르는가

들어가며 — "어떤 종목을 살까?"가 모든 문제의 시작이다

자동매매를 처음 만들면 누구나 똑같은 함정에 빠진다. 매매 로직, 손절선, 익절 비율을 먼저 고민하기 시작한다. 그런데 정작 "어떤 종목 풀에 이 로직을 적용할 것인가"를 빼먹는다.

종목 풀이 잘못되면 어떤 전략도 못 살린다. KOSPI/KOSDAQ 합쳐 2,500개가 넘는 종목 중에서 무작위로 10개를 골라 백테스트하면, 같은 vwap_ema 전략으로도 결과가 천차만별이다. 어떤 종목은 Sharpe 3.0, 어떤 종목은 -1.0. 종목 선정이 사실상 알파의 60~70%를 결정한다.

지난 6주 동안 한국 주식 자동매매 시스템을 만들면서 가장 많이 다듬은 부분도 결국 종목 선정 파이프라인이었다. 오늘은 이 4단 필터링이 어떻게 동작하는지, 그리고 각 단계가 왜 필요한지를 공유한다.


전체 파이프라인 — 2,500개에서 10개까지

KOSPI/KOSDAQ 전종목 (~2,500개)
        │
        ▼  Filter 1: 재무 점수 (≥ 70점, 5-metric × 5분기)
        │
재무 우량 종목 (~200개)
        │
        ▼  Filter 2: 테마 분류 (Naver 테마 + YouTube AI)
        │
테마 매칭 종목 (~50개)
        │
        ▼  Filter 3: 매수 신호 4조건 + signal_score
        │
신호 확정 종목 (~10개)
        │
        ▼  ticker_agent: 차트 5지표 + 적정가 7지수 + 거래주체
        │
운영 종목 (3~7개, anchor 합 100%)

각 단계는 앞 단계의 노이즈를 제거하는 역할을 한다. 한 단계라도 빼면 다음 단계가 노이즈에 휘말려 의미 없는 결과를 낸다. 거꾸로, 4단계를 모두 통과한 종목은 재무도 우량하고, 테마도 살아 있고, 차트 신호도 떴고, AI도 강세로 판단한 종목이다.


① Filter 1 — 재무 점수 (core/screening/scorer.py)

왜 재무 점수가 첫 번째인가

전략·신호·테마 다 좋아도 재무가 부실한 종목은 일주일에 30% 빠진다. 단기 트레이딩이라도 이 위험은 피해야 한다. 그래서 재무가 가장 첫 게이트다.

채점 방식 — 5 metric × 5분기

# core/screening/scorer.py 핵심
THRESHOLD_FINANCE_SCORE = 70   # config.yaml

METRICS = [
    "ROE",                    # 자기자본이익률
    "Operating Margin",       # 영업이익률
    "Net Profit Margin",      # 순이익률
    "PBR",                    # 주가순자산비율
    "PER",                    # 주가수익비율
]

# 분기별 비교: Q-4 ~ Q0 (현재) 5개 분기
# 각 metric × 각 분기 = 25 cell
# cell 점수: 임계값 만족 → 4점, 보통 → 2점, 미달 → 0점
# Total: 0 ~ 100점

각 분기를 별도로 채점하는 이유는 한 분기만 잠깐 좋아진 종목을 거르기 위함이다. 5분기 연속 우량이어야 70점을 넘는다. 추세가 살아 있는 종목만 잡힌다.

실제 작동 — sysTrade 이식

이 채점 로직은 sysTrade 원본을 거의 그대로 이식했다. config.yaml에 임계값을 넣어 운영 중 튜닝 가능하게 만들었다.

# config.yaml
financial_score:
  threshold: 70                          # 70점 이상만 통과
  metrics:
    ROE:                  { good: 15, normal: 5 }   # 단위 %
    operating_margin:     { good: 10, normal: 3 }
    net_profit_margin:    { good: 8,  normal: 2 }
    pbr:                  { good: 1.5, normal: 3.0 }  # 낮을수록 good
    per:                  { good: 15,  normal: 25 }

run_finance_report.sh 실행 → output/reports/finance-report.html 생성 → 통과 200여 종목 추출.


② Filter 2 — 테마 분류 (core/screening/theme_filter.py)

왜 테마가 두 번째인가

한국 시장은 테마장이 강하다. 같은 재무 우량 종목이라도 시장 관심이 없는 섹터는 몇 달간 횡보한다. 반대로 테마에 편입된 종목은 짧게는 1주일, 길게는 한 달 안에 +20~50% 움직인다.

문제는 "테마"가 빠르게 변한다는 점이다. 작년에는 2차전지, 올해는 원전·AI 인프라. 한 달 전 인기 테마가 지금은 시들 수 있다. 그래서 실시간 테마 검출이 필요하다.

5가지 모드의 테마 필터

# core/screening/theme_filter.py
MODES = [
    "etf_momentum",   # ETF 가중합 모멘텀 (1M×40% + 3M×30% + 6M×20% + 1Y×10%)
    "kis_mst",        # KIS Master 데이터 + 업종 키워드 매칭
    "youtube_ai",     # YouTube 5채널 AI 분석 (Apify + GPT)
    "naver",          # Naver 공식 테마 (≈450개, 신설 2026-04-20)
    "all",            # 위 4개 합집합
]

Naver 테마 크롤러 — de-facto 표준 (2026-04-20 신설)

처음에는 LLM이 자유 텍스트로 sector를 생성하게 했다. "원전 인프라", "AI 에이전트" 등. 그런데 LLM이 같은 종목을 다른 이름으로 분류하거나, 한국 시장에서 통용되지 않는 용어를 만들어내는 문제가 발생했다.

해결: Naver 금융의 공식 테마(약 450개)를 단일 진실원으로 채택. 매일 08:45에 크롤링해서 naver_theme_cache + naver_theme_tickers 두 테이블에 저장. LLM은 이 whitelist 안에서만 분류하도록 prompt 제약.

# core/screening/naver_theme_crawler.py
def crawl_naver_themes():
    """매일 08:45 cron + 주 1회 일요일 20:00"""
    themes = parse("https://finance.naver.com/sise/theme.naver")
    for t in themes:
        tickers = parse(f".../theme_detail?themeId={t.id}")
        save_to_db(t, tickers)
    diff = compare_with_yesterday()
    if diff.new_themes:
        send_telegram_alert(f"신규 테마 감지: {diff.new_themes}")

YouTube AI 분석 — 5개 채널 시황 자동 요약

screening/youtube_analyzer.py가 매일 5개 시황 채널의 최근 7일 영상을 자동 분석한다.

1. Apify로 transcript 추출 (karamelo~youtube-transcripts)
2. GPT-5.4 / Claude Sonnet으로 섹터·종목·stance 추출
3. SQLite 3일 TTL 캐시
4. 5채널 결과 집계 → 다수결로 strong sector 도출

실제 결과 (2026-04-17): Focus Now 채널 9개 섹터 도출, Watch List 채널 38개 섹터. 5채널 합집합 → 신뢰도 높은 8~10개 strong sector 추출.


③ Filter 3 — 매수 신호 (core/screening/signals.py)

4조건 + signal_score (0~4점)

재무 우량 + 테마 매칭까지 통과한 ~50개 중에서, 지금 매수해도 되는 종목만 추출하는 단계다.

# core/screening/signals.py
def compute_buy_signal(ticker: str) -> SignalScore:
    score = 0
    reasons = []

    # 조건 1: 재무 우량 (이미 통과했지만 score에 반영)
    if finance_score(ticker) >= 70:
        score += 1
        reasons.append("재무우량")

    # 조건 2: 평가절하 (현재가 < 적정가 composite)
    if current_price < fair_value_composite(ticker):
        score += 1
        reasons.append("평가절하")

    # 조건 3: 거래량 급증 (당일 거래량 / 20일 평균 ≥ 2.0)
    if volume_today / volume_20d_avg >= 2.0:
        score += 1
        reasons.append("거래량급증")

    # 조건 4: 양봉 + MA 정배열 (close > MA5 > MA20 > MA60)
    if is_bullish_alignment(ticker):
        score += 1
        reasons.append("양봉정배열")

    return SignalScore(score=score, reasons=reasons)

signal_score ≥ 3 종목만 다음 단계로 넘어간다. 4점이면 강신호, 3점이면 보통 신호.

확장 — 프로그램 매수 서지 (2026-04-20 등록)

기존 4조건에 5번째를 추가하는 작업이 등록되어 있다.

# 조건 5: 프로그램 순매수 서지 (당일 / 20일 평균 ≥ 2.0)
if program_net_buy_today / program_net_buy_20d_avg >= 2.0:
    score += 1
    reasons.append("프로그램서지")

외인·기관의 프로그램 매매가 갑자기 늘어나는 종목은 펀더멘털 정보가 빠르게 반영되고 있다는 신호. 데이터는 KIS API program-trade-by-stock 또는 pykrx + KRX 공시.


④ ticker_agent의 차트 5지표 + 적정가 7지수 + 거래주체

Filter 1~3을 통과한 종목은 이제 ticker_agent(Phase B, 2026-05-15 완성)가 봉별로 평가한다.

차트 5지표 (core/agents/ticker_agent.py)

ticker_agent는 단일 종목의 차트만 본다. 포트폴리오 비중, 현금, max_position_pct 등은 일부러 입력에서 제거했다. 이유: ticker_agent가 다른 종목의 영향을 받아 판단이 흔들리는 것을 막기 위해.

입력 5지표:
  ① 적정주가     — 현재가 vs composite fair value
  ② 매집도       — OBV 추세 + 누적 거래대금
  ③ ADX          — 추세 강도 (DI+ / DI- / ADX)
  ④ 상대 거래량  — 당일 / 20일 평균
  ⑤ 매물대 공백  — Volume Profile 빈 구간

출력 (BUY):
  { "decision": "BUY"|"HOLD",
    "signal_strength": 0.0~1.0,
    "reasoning": "한 문장",
    "confidence": 0.0~1.0 }

적정 가격 지수 7개 (core/indicators/fair_value.py)

단일 metric으로는 평가가 어렵다. 7개를 composite로 묶었다.

#

지수

계산

1

PER 업종 비교

종목 PER vs 업종 평균 (할인율)

2

PBR 업종 비교

종목 PBR vs 업종 평균

3

단순 DCF

FCF × 5년 + 잔존가치 / WACC

4

기술적 지지·저항

최근 60일 거래대금 가중 매물대

5

수급 (flow)

외인·기관 5일 누적 순매수

6

옵션 IV

내재 변동성 → 시장의 예상 변동폭

7

업종 분위

같은 업종 종목들 중 상대 순위

+

뉴스 sentiment

scout_signals의 24h 평균 sentiment

7개를 가중 합산 → composite fair value 산출. ticker_agent prompt에 [C-1] 적정가 7지수 + composite 블록으로 주입.

거래주체 추적 (core/indicators/flow.py)

# 15:35 cron — 매일 종가 직후
def daily_flow_update():
    for ticker in active_picks:
        flow = pykrx.get_market_trading_value_by_investor(ticker)
        # 외인 / 기관 / 개인 일일 net
        save_to_db("flow_data", flow)

# ticker_agent input 에 [C-2] 블록으로 주입:
#   외인 5일 누적: +12.3억 (매수 우세)
#   기관 5일 누적: +5.7억
#   개인 5일 누적: -18.0억 (개미 매도)
#   dominant: 외인+기관 / trend: 매집

외인·기관이 5일 연속 매수하고 개인이 매도하는 패턴은 펀더멘털 호재가 시장에 빠르게 반영되고 있다는 신호. ticker_agent가 이걸 보고 signal_strength를 1.0으로 올린다.


⑤ ticker_prompts — 종목별 매매 철학 주입

같은 차트 신호라도 종목마다 다르게 해석해야 한다. 반도체 장비주는 짧은 모멘텀, 원전 인프라는 긴 추세. 이걸 코드에 박지 않고 종목별 system prompt로 분리했다.

-- ticker_prompts 테이블
CREATE TABLE ticker_prompts (
    id INTEGER PRIMARY KEY,
    ticker TEXT,
    version INTEGER,
    prompt_text TEXT,
    is_active INTEGER,
    note TEXT,
    created_at TEXT
);

UI: StepTelegram → "프롬프트 튜닝" 탭 → 종목 선택 → 편집 → 새 버전 저장 → 활성화. 다음 cron부터 신규 프롬프트로 평가.

예시:

[종목 010120 — LS ELECTRIC]
당신은 원전·전력 인프라 장기 추세 추종 매매를 지향합니다.
9EMA 되돌림에서 적극 매수, 매물대 공백이 큰 경우 FULL 진입.
단기 -3% 출렁임은 무시. 진정한 위험은 ADX < 15 추세 소실.

이 프롬프트가 ticker_agent prompt의 [D] 사용자 매매 철학 블록으로 자동 주입된다.


운영 예시 — 2026-05-04 후보 6종

지금 시스템이 운영 중인 후보 종목들이다.

코드

종목명

테마 (Naver)

finance

signal_score

010120

LS ELECTRIC

전력 인프라

85

4

033100

제룡전기

전력 인프라

72

3

052690

한전기술

원전

78

4

084370

유진테크

반도체 장비

81

4

095610

테스

반도체 장비

75

3

403870

HPSP

반도체 장비

(미정)

(미정)

테마는 원전·전력 인프라 + 반도체 장비 2개 섹터로 자연스럽게 분산됐다. 4단계 필터링이 의도적인 분산까지 만들어낸다.


결론 — 종목 선정은 데이터 파이프라인 문제다

"좋은 종목을 고른다" = 직관
"4단 필터링을 통과시킨다" = 데이터 파이프라인

직관은 흔들리지만 파이프라인은 흔들리지 않는다. 매일 같은 룰로 2,500개에서 10개를 추출한다. 결과가 마음에 안 들면 룰을 고친다 — 종목을 직접 고르지 않는다.

다음 주(2주차)에는 이렇게 추출된 후보 종목들을 3개의 AI 에이전트(scout/ticker/strategy)가 어떻게 협업해서 실제 매매하는지를 다룬다. 종목 선정은 출발선일 뿐, 진짜 알파는 그 뒤에 있다.

11
4개의 답글
밀어주고 끌어주는

온·오프라인 AI 스터디

AI로 어디까지 할 수 있는지
직접 확인하실 분만 신청하세요.