왜 만들었나

QA 자동화 테스트 코드를 작성할 때 가장 반복적이고 시간을 잡아먹는 작업이 뭘까? 바로 엘리먼트 셀렉터를 뽑아내는 일이다.

DevTools를 열고, Elements 탭에서 엘리먼트를 찾고, 적절한 셀렉터를 조합하고, 그게 유니크한지 Console 탭에서 document.querySelectorAll()로 확인하고... 한 페이지에 엘리먼트가 20개만 돼도 이 과정이 고통스러워진다.

기존 워크플로우의 문제

DevTools 열기 → Elements 탭 → 엘리먼트 검색 → 속성 확인 → Console에서 유니크 여부 확인 → 셀렉터 복사 → 테스트 코드에 붙여넣기. 엘리먼트 하나에 최소 30초~1분이 소요된다.

이걸 "클릭 한 번"으로 줄일 수 없을까? 라는 생각에서 Selector Extractor 크롬 확장 프로그램을 만들었다. 클릭하면 말풍선에 셀렉터 목록이 뜨고, 유니크한 것이 상단에 정렬되고, Playwright 코드까지 자동으로 생성해준다.

동작 흐름

[1] 확장 프로그램 활성화
       ↓  팝업에서 토글 ON
[2] 엘리먼트 클릭
       ↓  Content Script가 클릭 이벤트 캐치
[3] 셀렉터 추출 + 말풍선 표시
       ↓  7종 셀렉터 동시 추출, 우선순위 정렬
[4] 사이드 패널 히스토리 누적
       ↓  Background Service Worker 중계
       검색 / JSON / CSV 내보내기
  1. 활성화 - 팝업에서 토글 버튼을 클릭하면 Content Script가 활성화된다.
  2. 하이라이트 - 마우스를 올리면 해당 엘리먼트가 파란 테두리로 하이라이트된다.
  3. 클릭 → 추출 - 엘리먼트를 클릭하면 SelectorEngine.extract()가 7종류의 셀렉터를 한꺼번에 추출한다.
  4. 말풍선 표시 - 추출된 셀렉터들이 우선순위대로 말풍선에 표시된다. 각 항목 옆에 Copy 버튼이 있다.
  5. 사이드 패널 히스토리 - Background Service Worker를 통해 추출 결과가 사이드 패널로 전달되어 히스토리에 쌓인다.

핵심 기술 - Manifest V3 아키텍처

Chrome의 Manifest V3 스펙에 맞춰 설계했다. 구성 요소는 크게 4가지다.

manifest.json

{
  "manifest_version": 3,
  "permissions": ["activeTab", "sidePanel", "storage", "clipboardWrite"],
  "content_scripts": [{
    "js": ["lib/selector-engine.js", "content.js"],
    "css": ["styles/content.css"],
    "run_at": "document_idle"
  }],
  "background": { "service_worker": "background.js" },
  "side_panel": { "default_path": "sidepanel.html" }
}

컴포넌트 역할

컴포넌트파일역할
Content Scriptcontent.js + selector-engine.js페이지에 주입, 하이라이트/클릭/말풍선 처리
Backgroundbackground.js메시지 브릿지, 탭별 상태 관리
Side Panelsidepanel.js히스토리 관리, 검색, 내보내기
Popuppopup.jsON/OFF 토글 UI

Manifest V3에서는 Background Page 대신 Service Worker를 사용한다. Service Worker는 필요할 때만 깨어나고 유휴 시 자동 종료되므로, 상태를 메모리에만 들고 있으면 안 된다. 이 프로젝트에서는 히스토리를 chrome.storage.local에 저장하고, 탭 상태만 tabStates 객체로 관리하는 방식을 택했다.

메시지 플로우

Content Script → chrome.runtime.sendMessage → Background (브릿지) → Side Panel. Content Script와 Side Panel은 직접 통신할 수 없기 때문에 Background가 중계 역할을 한다.

셀렉터 엔진 - 우선순위 알고리즘

핵심은 lib/selector-engine.jsSelectorEngine.extract() 함수다. 하나의 엘리먼트에서 7가지 타입의 셀렉터를 동시에 추출한다.

우선순위타입예시안정성
1id#login-btnHIGH
2data-testid[data-testid="submit"]HIGH
3class (unique).user-profile-cardMEDIUM
4cssdiv.container > button:nth-of-type(2)MEDIUM
5xpath//div[@id="app"]/form/button[2]LOW
6rolerole=button[name="Submit"]MEDIUM
7texttext="로그인"LOW

각 추출 함수에는 노이즈를 줄이기 위한 필터링 로직이 있다.

selector-engine.js · getByUniqueClass()

function getByUniqueClass(el) {
  const classes = Array.from(el.classList);
  for (const cls of classes) {
    if (cls.length < 3) continue;                    // 너무 짧은 클래스 무시
    if (/^se-/.test(cls)) continue;                    // 확장 프로그램 자체 클래스 무시
    if (/^[a-z]{1,3}-[a-zA-Z0-9]{5,}$/.test(cls)) continue;  // 해시 기반 동적 클래스
    if (/^css-/.test(cls)) continue;                   // CSS-in-JS 클래스
    if (/^_/.test(cls)) continue;                      // 언더스코어 시작 (내부용)

    const selector = `.${CSS.escape(cls)}`;
    if (isUniqueSelector(selector)) {
      return { selector };
    }
  }
  return null;
}

React, Vue, Styled-Components 등 CSS-in-JS 라이브러리가 생성하는 해시 클래스(css-1a2b3c, sc-bZQynM 등)는 빌드마다 바뀌기 때문에 의미 없다. 이런 패턴을 정규식으로 걸러낸다.

유니크 셀렉터 우선 정렬이 중요한 이유

단순히 id → class → xpath 순서로 보여주면 될 것 같지만, 실전에서는 그렇지 않다. id가 있어도 페이지에서 유니크하지 않을 수 있고, class 셀렉터가 오히려 유니크할 수도 있다.

selector-engine.js · 정렬 로직

// 유니크 여부 체크 + 매치 수 측정
results.forEach(r => {
  r.unique = checkUnique(r);
  r.matchCount = countMatches(r);
});

// 정렬: 유니크 우선 → 매치 수 적은 순 → 기존 priority
return results.sort((a, b) => {
  if (a.unique !== b.unique) return a.unique ? -1 : 1;
  if (a.matchCount !== b.matchCount) return a.matchCount - b.matchCount;
  return a.priority - b.priority;
});

정렬 기준은 3단계다:

  1. 유니크 여부 - 페이지에서 1개만 매칭되는 셀렉터가 최상위로 올라온다.
  2. 매치 수 - 유니크하지 않다면 매치 수가 적은 것이 상위.
  3. 기본 우선순위 - 나머지가 같다면 id > data-testid > class 순서.

실제 효과

말풍선에서 첫 번째 셀렉터를 그냥 복사하면 된다. 그 셀렉터가 해당 페이지에서 가장 안정적이고 유니크한 선택지라는 것을 엔진이 보장한다. 유니크 셀렉터에는 초록색 UNIQUE 뱃지가 표시되고, 유니크하지 않은 경우에는 노란색으로 매치 수가 표시된다.

Playwright 코드 자동 생성

셀렉터를 뽑는 것에서 끝나지 않는다. 가장 좋은 셀렉터를 기반으로 Playwright Python 코드를 즉시 생성해준다.

selector-engine.js · toPlaywright()

function toPlaywright(selectorResult) {
  const { type, selector } = selectorResult;
  switch (type) {
    case 'id':
      return `page.locator("${selector}")`;
    case 'data-testid':
      return `page.get_by_test_id("${...}")`;
    case 'role':
      return `page.get_by_role("${role}", name="${name}")`;
    case 'text':
      return `page.get_by_text("${text}")`;
    case 'xpath':
      return `page.locator("xpath=${selector}")`;
    default:
      return `page.locator("${selector}")`;
  }
}

셀렉터 타입에 따라 Playwright의 시맨틱 API를 적절히 매핑한다. data-testid이면 get_by_test_id(), role이면 get_by_role(), text이면 get_by_text()를 사용한다.

생성되는 코드 예시:

자동 생성된 Playwright 코드

# Locator
page.get_by_test_id("submit-button")

# Click action
await page.get_by_test_id("submit-button").click()

# Fill action
await page.get_by_test_id("submit-button").fill("")

# Assertion
await expect(page.get_by_test_id("submit-button")).to_be_visible()

말풍선과 사이드 패널 양쪽에서 각 액션 코드를 개별 복사할 수 있다. QA 엔지니어가 테스트 코드를 작성할 때 복붙만 하면 되는 수준까지 자동화된다.

JSON / CSV 내보내기

사이드 패널에 쌓인 히스토리를 JSON 또는 CSV 형식으로 내보낼 수 있다. 한 페이지의 셀렉터를 쭉 뽑아놓고 한 번에 내보내면 테스트 스펙 문서로도 활용 가능하다.

sidepanel.js · JSON 내보내기

const exportData = history.map(item => ({
  url:        item.url,
  element:    item.elementInfo,
  selectors:  item.selectors,
  playwright: item.playwright,
  timestamp:  item.timestamp,
}));

downloadFile(
  JSON.stringify(exportData, null, 2),
  `selectors-${formatDate(new Date())}.json`,
  'application/json'
);

CSV 형식은 스프레드시트에서 바로 열 수 있도록 헤더를 포함한다:

selectors-20260402_1230.csv

Timestamp,URL,Tag,Best Selector Type,Best Selector,Playwright Locator
2026-04-02T12:30:00,https://example.com,button,data-testid,"[data-testid=""submit""]","page.get_by_test_id(""submit"")"
2026-04-02T12:30:05,https://example.com,input,id,"#email","page.locator(""#email"")"

히스토리는 chrome.storage.local에 최대 200개까지 저장된다. 검색 기능도 있어서 셀렉터 값, 태그 이름, 텍스트 내용으로 필터링할 수 있다.

개발 과정에서 배운 점

Manifest V3의 메시지 통신

Content Script와 Side Panel은 직접 통신할 수 없다. 반드시 Background Service Worker가 중간에서 메시지를 릴레이해야 한다. 처음에는 이 구조를 몰라서 chrome.runtime.sendMessage로 직접 보냈다가 메시지가 사라지는 현상을 겪었다.

background.js · 메시지 브릿지

// Content Script에서 온 셀렉터 결과를 Side Panel로 전달
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'selectorExtracted') {
    chrome.runtime.sendMessage({
      type: 'newSelector',
      data: msg.data,
    }).catch(() => {
      // 사이드 패널이 닫혀있으면 무시
    });
  }
  return true; // async sendResponse 유지
});

자기 자신을 선택하는 문제

Content Script가 만든 말풍선 DOM이 사용자의 클릭 이벤트를 가로채는 문제가 있었다. se- 접두사로 확장 프로그램의 모든 클래스를 네이밍하고, isOwnElement() 함수로 자체 UI 요소를 클릭 대상에서 제외시켰다. 또한 셀렉터 추출 시 se-highlight 클래스가 포함되지 않도록 추출 직전에 해당 클래스를 제거하는 순서도 중요했다.

Windows 환경에서의 PIL 아이콘 생성

아이콘을 Pillow(PIL)로 프로그래밍 방식으로 생성했다. 4배 크기 캔버스에 그린 뒤 Image.LANCZOS로 다운스케일하여 안티앨리어싱을 확보하는 방식이다. 크로스헤어 + 그리드 + 초록 포커스 포인트로 "Precision Signal" 디자인 컨셉을 잡았다.

generate_icons.py

def create_icon(size):
    scale = 4
    s = size * scale + 1  # 홀수로 만들어 정확한 중앙 픽셀 확보
    img = Image.new('RGBA', (s, s), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)

    # ... 크로스헤어, 링, 코너 브래킷 등 그리기 ...

    # 고품질 안티앨리어싱으로 다운스케일
    img = img.resize((size, size), Image.LANCZOS)
    return img

동적 클래스 필터링의 중요성

실제 웹 앱에서 테스트해보니 CSS-in-JS 라이브러리가 생성하는 클래스가 셀렉터를 심각하게 오염시켰다. css-1a2b3c, sc-bZQynM, _1f3ab 같은 패턴은 빌드할 때마다 바뀌어서 테스트 셀렉터로 쓸 수 없다. 정규식 패턴 매칭으로 이런 클래스를 걸러내는 것이 예상보다 큰 효과를 가져왔다.

정리

  • Manifest V3 - Service Worker 기반의 새로운 확장 프로그램 아키텍처
  • 7가지 셀렉터 동시 추출 - id, data-testid, class, css, xpath, role, text
  • 유니크 우선 정렬 - 페이지에서 1개만 매칭되는 셀렉터를 최상위로
  • 동적 클래스 필터링 - CSS-in-JS 해시 클래스를 정규식으로 제거
  • Playwright 자동 생성 - 셀렉터 타입에 따라 시맨틱 API 매핑
  • JSON / CSV 내보내기 - 히스토리 200개까지 누적, 테스트 스펙 문서로 활용

결론

QA 자동화에서 셀렉터 추출은 단순 반복 작업이지만, 매일 수십~수백 번 반복되면 상당한 시간 비용이 된다. 도구 하나로 이 과정을 클릭 한 번 + 복사 한 번으로 압축할 수 있다면 그 자체로 가치가 있다. Manifest V3, Content Script 통신 구조, 셀렉터 우선순위 알고리즘 등 크롬 확장 프로그램 개발에 관심이 있다면 직접 만들어보는 것을 추천한다.

'테스트 자동화 > 일반' 카테고리의 다른 글

테스트 자동화 구현예제  (0) 2022.04.27
테스트 자동화  (0) 2022.04.04

들어가며

"이번 주 시장이 어땠지?" - 이 질문에 답하려면 보통 여러 곳을 돌아다녀야 한다. 지수는 여기, 상승 종목은 저기, 수급은 또 다른 곳. 매주 금요일마다 이걸 정리하는 건 꽤 번거로운 일이다.

그래서 주간/월간 시장 리뷰를 자동으로 생성하는 기능을 만들었다. DB에 쌓인 데이터를 기반으로 "이번 주(또는 이번 달) 시장에서 무슨 일이 있었는지"를 5분 안에 정리해준다.

리뷰 페이지 - 대시보드형 레이아웃

매주 금요일(마지막 거래일), 매월 말에 자동으로 리뷰를 생성한다. 2026년 1월부터 소급해서 백필도 가능하다.

리뷰 페이지는 단일 화면의 대시보드 구조다. 위에서 아래로 6개 블록:

  1. 툴바 - 주간/월간 토글 버튼 + 기간 드롭다운(과거 리뷰 목록). URL 동기화(/review?type=weekly)
  2. 헤더 지수 칩 - 기간(2026-04-14~18) + KOSPI/KOSDAQ 종가·포인트(pt)·등락률을 칩 하나에
  3. 📋 시장 요약 - AI가 DB 숫자를 기반으로 만든 4종 요약 + 📰 주요 뉴스
  4. 지수 차트 2종 - KOSPI·KOSDAQ 개별 미니 라인차트(Chart.js), 상승/하락에 따라 빨강·파랑 자동 적용
  5. 15가지 랭킹 패널 - 종목 TOP 10(상승·하락) × 2, ETF TOP 10(상승·하락) × 2, 테마 TOP 10(상승·하락) × 2, 수급 TOP(외국인·기관·연기금·개인 × 순매수·순매도) × 8, 지수 차트 1 = 합 15개
  6. 클릭 연동 - 모든 종목 행 클릭 시 종목 상세 페이지로 이동

잡주 제거 기준은 거래대금 50억 이상. 이 필터가 없으면 거래대금 1억짜리 관리종목이 "+30%"로 상위에 올라와서 무의미한 정보가 된다.

 

주간 리뷰 상단 - AI 요약 + KOSPI/KOSDAQ 차트 + 종목/ETF 랭킹

AI 자동 요약 - 4종 + 뉴스

숫자만 나열하면 읽기 힘드니까, 리뷰 데이터를 기반으로 AI가 텍스트 요약을 생성한다. 요약에는 다음이 포함된다:

  • 헤드라인 - 한 줄로 기간을 요약 (예: "KOSPI 반도체 랠리로 5,400 돌파, 외국인 순매수 전환")
  • 내러티브 - 3~4문장으로 시장 흐름 설명
  • 수급 분석(flow_analysis) - 투자자별 매매 패턴 요약
  • 글로벌 비교(global_comparison) - 같은 기간 S&P 500·나스닥·닛케이 등 해외 지수와의 상대 성과

직접 분석을 작성하는 게 아니라, DB의 숫자를 기반으로 AI가 자동 생성한다. 사람이 쓴 것 같지만 모든 내용이 데이터에서 나온다.

📰 주요 뉴스 - 주간 vs 월간 다른 렌더링

뉴스 하이라이트는 요약 블록 내부에 함께 들어간다. 단, 포맷이 기간 유형에 따라 다르다:

  • 주간 리뷰 - 주요 뉴스를 rv-news-inline 칩 스타일로 한 줄에 나열. 주간은 5~10건 수준이라 한 줄이면 충분
  • 월간 리뷰 - 날짜별로 그룹핑해서 표시. "4/1", "4/8", "4/15" 같은 주차 라벨 아래에 해당 날짜 뉴스가 붙음. 한 달치를 연대순으로 훑을 수 있게

중요도가 높은 뉴스는 hot 플래그가 붙어서 붉은 테두리로 강조된다. 같은 뉴스라도 리뷰 성격에 따라 읽는 맥락이 달라지니, UI도 맞춰서 바뀌어야 했다.

패널 UI - 3종 테이블 + 1종 카드

랭킹 패널은 성격에 따라 포맷을 다르게 잡았다. 같은 "TOP N"이라도 사용자가 보고 싶은 정보가 다르기 때문이다.

1. 종목 TOP 10 (상승·하락, ETF 상승·하락)

심플한 4컬럼 테이블 - 순위 / 종목명 / 등락률 / 종가. 등락률은 색상(상승 빨강, 하락 파랑)으로 구분. 종목명 행 전체가 클릭 영역이라 누르면 종목 상세 페이지로 바로 이동한다. "오늘의 폭등주"를 눈으로 훑고 바로 차트 여는 흐름이 한 번의 클릭으로 끝난다.

2. 수급 TOP (외국인·기관·연기금·개인, 각 순매수/매도)

4컬럼 테이블 - 순위 / 종목명 / 금액(억) / 등락률. 종목 테이블과 달리 금액 컬럼이 핵심이라 중앙에 배치. fmt.signed()로 +/- 부호를 명시해서 매수·매도 방향이 텍스트로 바로 읽힌다. 헤더에는 이모지(🌍외국인 / 🏦기관 / 🏛연기금 / 👤개인)를 붙여서 8개 패널이 바닥에 깔려도 헤더만 훑으면 주체가 구분된다.

3. 테마(섹터) TOP 10 - 카드형

섹터만 테이블이 아니라 카드 형태로 만들었다. 이유: 섹터 하나를 이해하려면 "평균 등락률" 한 숫자로는 부족하고, 그 섹터에서 어떤 종목이 끌고 갔는지가 따라붙어야 한다. 각 카드에 넣은 5요소:

  • 순위 + 테마명 + 평균 등락률 (헤드 라인)
  • 등락률 진행 바 - 절댓값 × 5% 너비 (max 100%). 숫자로는 체감 안 되는 강도를 시각으로 채워줌
  • 종목 수 + ↑상승 · ↓하락 카운트 - 예: "32종목 ↑24 ↓8" (폭이 넓은지 좁은지 판단)
  • 상위 5종목 태그 - 종목명 + 개별 등락률 칩 형태. 태그 자체도 클릭 가능(종목 상세로 이동)

"2차전지 +4.2%" 카드 아래에 "에코프로비엠 +8.2%, LG에너지솔루션 +5.1%, 포스코퓨처엠 +3.7%..." 태그가 깔린다. 어떤 섹터가 왜 올랐는지를 카드 한 장으로 파악할 수 있다. 테이블로 만들면 행마다 태그 5개씩 우겨넣어야 해서 가독성이 무너지는데, 카드 레이아웃이면 자연스럽게 소화된다.

왜 포맷을 섞었나

"데이터가 표현을 결정한다"는 원칙이었다. 종목 랭킹은 "종목 하나당 숫자 하나"라 테이블이 적합하고, 수급은 "종목 하나당 숫자 두 개(금액·등락)"라도 여전히 테이블이 효율적이다. 반면 섹터는 "섹터 하나당 종목 5~10개 + 숫자 3~4개"라 정보량이 2차원이라 카드가 맞다. 모든 패널을 테이블로 통일했다면 화면은 깔끔했겠지만, 섹터 정보는 거의 못 읽을 뻔했다.

테마 top 10 / 수급 패널 8종

레이아웃

리뷰 페이지는 4열 그리드로 배치했다. 지수 차트는 2열(KOSPI + KOSDAQ), 종목·ETF 랭킹은 4열, 수급 8종은 4열 2행, 테마 카드는 2열 풀폭. 화면을 줄이면 2열 → 1열로 자동 조정된다.

지수 차트는 Chart.js로 그린다. 주간이면 5일치, 월간이면 20일치 선차트가 나온다. 상승 기간이면 빨강, 하락이면 파랑으로 선 색이 바뀌어서 등락률과 시각이 직관적으로 일치한다. Y축은 데이터 범위에 8% 여백만 주고 밀착시켜 변동이 잘 보이게 했다.

수급 데이터는 외국인/기관/연기금/개인 × 순매수/순매도 = 8개 테이블인데, 4열 2행으로 배치해서 "순매수 행 / 순매도 행"으로 구분했다. 각 투자자마다 이모지(🌍외국인 / 🏦기관 / 🏛연기금 / 👤개인)를 붙여서 헤더만 봐도 구분된다.

툴바는 맨 위에 고정. 주간/월간 토글 버튼 옆에 드롭다운이 있어서 과거 리뷰 목록을 바로 선택할 수 있다. 드롭다운 항목은 "2026년 4월 3주" / "2026년 4월" 같은 한글 라벨로 표시하고, 내부 키는 ISO 주차(2026-W16)와 YYYY-MM 형식. 선택 시 URL도 동기화돼서 링크 공유가 가능하다.

리뷰 생성 시점

리뷰는 해당 기간이 끝난 후에만 생성한다. 주간 리뷰는 금요일 장 마감 후, 월간 리뷰는 월말 마감 후. 아직 진행 중인 기간의 리뷰를 미리 만들면 불완전한 데이터가 들어가기 때문이다.

daily_update 배치에서 금요일인지 체크하고, 금요일이면 자동으로 주간 리뷰를 생성한다. 월말이면 월간 리뷰도 같이 만든다.

과거 리뷰가 없는 경우를 위해 백필 기능도 만들었다. 2026년 1월 1일부터 현재까지의 모든 주간/월간 리뷰를 소급 생성할 수 있다.

삽질 기록

15개 랭킹 레이아웃 잡기

15가지 랭킹을 한 페이지에 보기 좋게 배치하는 게 생각보다 어려웠다. 처음에는 모든 항목을 같은 너비로 나열했는데, 테마 카드가 좁은 칸에 갇히니까 읽기 힘들었다. 테마만 2열, 나머지는 4열로 분리하니까 훨씬 나아졌다.

주간 경계 처리

"이번 주"의 시작은 월요일인데, 공휴일이 끼면 거래일이 4일뿐인 주도 있다. 단순히 "최근 5영업일"로 계산하면 주 경계가 맞지 않는다. 결국 ISO 주차 기준(월~금)으로 정하고, 거래일만 필터링하는 방식으로 해결했다.

잡주 필터링

등락률 TOP 10을 그냥 뽑으면 거래대금 1억도 안 되는 잡주가 상위에 올라온다. "관리종목이 +30%"는 의미 없는 정보다. 거래대금 50억 이상 필터를 걸어서 의미 있는 종목만 남겼다.

정리

  • 자동 생성 - 매주 금요일 + 매월 말, DB 데이터 기반
  • 대시보드 구조 - 툴바 → 헤더 지수 칩 → AI 요약 → 지수 차트 2종 → 랭킹 15개 → 클릭 연동
  • AI 요약 4종 - 헤드라인·내러티브·수급 분석·글로벌 비교 + 📰 주요 뉴스(주간 한 줄 / 월간 주차별 그룹)
  • 15가지 랭킹 - 지수·종목(2)·ETF(2)·테마(2)·수급(8), 4열 그리드 + 테마 2열
  • 네비게이션 - 주간/월간 토글 + 드롭다운으로 과거 리뷰 선택, URL 동기화
  • 백필 - 과거 기간 소급 생성 가능

"이번 주 뭐가 있었지?"를 5분 만에 정리해주니까, 바쁜 주중에 시장을 못 봤더라도 금요일 저녁에 한 번 훑으면 된다. 숫자를 직접 찾아다닐 필요 없이 리뷰 페이지만 열면 된다.

들어가며

증권사 리포트를 읽어본 적 있는가? "삼성전자 목표가 300,000원, 투자의견 매수" - 이런 한 줄 요약을 보고 "그래서 사야 하나 말아야 하나" 고민한 적이 있을 것이다.

문제는 증권사가 30개가 넘고, 같은 종목에 대해 서로 다른 목표가를 제시한다는 것이다. 누구 말을 들어야 할까? 하나하나 찾아보기는 귀찮다.

그래서 모든 증권사의 추천을 한 곳에 모아서 보여주는 페이지를 만들었다.

증권사 추천 수집

증권사 컨센서스 데이터를 수집한다. 두 가지를 가져온다:

1. 컨센서스

여러 증권사의 목표가를 평균 낸 것이다. 종목마다 "평균 목표가", "투자의견 평균", "분석가 수"가 나온다.

투자의견은 숫자로 변환한다:

  • 4.5 이상 → 강력매수
  • 3.5~4.5 → 매수
  • 2.5~3.5 → 보유
  • 2.5 미만 → 비중축소 또는 매도

2. 개별 증권사 추천

증권사별 추천을 수집한다. 날짜, 목표가, 투자의견이 포함된다.

수집은 비동기 병렬로 처리한다. 3,970종목을 순차적으로 돌리면 한 시간이 넘는데, 병렬로 하면 수 분이면 끝난다.

6개월이 지난 추천은 자동으로 제거한다. 6개월 전 목표가는 현재 시장 상황과 맞지 않으니까.

3개 탭으로 보는 증권사 추천

탭 1: 컨센서스

가장 기본적인 뷰. 전 종목의 컨센서스를 한 테이블로 보여준다.

종목 현재가 목표가 상승여력 투자의견 분석가
동성화인텍 23,300 40,000 +71.7% 매수 4명
알테오젠 381,000 575,000 +50.9% 매수 3명
에이비엘바이오 180,100 251,667 +39.7% 매수 8명
한화솔루션 35,650 48,429 +35.8% 매수 9명
산일전기 149,300 200,000 +34.0% 매수 7명

상승여력 = (목표가 - 현재가) / 현재가. 증권사들이 현재 가격보다 얼마나 더 오를 거라고 보는지를 퍼센트로 보여준다. 상승여력순으로 정렬하면 "증권사들이 가장 저평가되었다고 보는 종목"이 위로 온다.

탭 2: 증권사 랭킹

"어떤 증권사 말을 들어야 하나?"에 대한 답을 주는 탭이다.

최근 6개월간 각 증권사가 추천한 종목의 실제 성과를 추적한다:

  • 적중률: 현재가가 목표가에 도달한 비율
  • 평균수익률: 추천 시점 대비 현재 수익률

적중률이 높은 증권사의 추천을 더 신뢰할 수 있다. 물론 과거 성과가 미래를 보장하지는 않지만, "이 증권사는 목표가를 너무 높게 잡는 경향이 있다" 같은 패턴은 파악할 수 있다.

탭 3: 공통 추천

"여러 증권사가 동시에 추천하는 종목"을 찾아준다.

한 증권사만 추천하면 개인 의견이지만, 5개 증권사가 동시에 추천하면 시장의 합의에 가깝다. 최소 추천 증권사 수(2~5개)와 기간(1~12개월)을 필터로 조절할 수 있다.

각 종목에 어떤 증권사가 추천했는지 배지로 표시하고, 평균 목표가와 목표가 범위(최저~최고)도 보여준다.

증권사 추천의 한계

솔직히 말하면, 증권사 목표가를 그대로 믿는 투자자는 없다. 이유가 있다:

  • 매도 의견이 거의 없다: 증권사는 기업과 관계를 유지해야 하니까, "매도"를 잘 안 쓴다. 대부분 "매수"나 "보유"다.
  • 목표가가 높은 편이다: 현재가보다 30~40% 높은 목표가가 흔하다. 도달하지 못하는 경우가 더 많다.
  • 후행적이다:  주가가 올라간 후에 목표가를 올리는 경우가 있다.

그래서 증권사 추천은 "절대적인 매매 신호"가 아니라 "참고 자료"로 써야 한다. 여러 증권사의 의견을 모아서 보면 시장의 분위기를 읽는 데 도움이 된다. 그 정도의 가치가 있다.

삽질 기록

증권사마다 데이터 형식이 다르다

외부 데이터를 수집하다 보면, 같은 정보인데 증권사마다 목표가 형식이 달랐다. 어떤 곳은 "95,000원", 어떤 곳은 "95000", 어떤 곳은 빈칸. 투자의견도 "매수", "Buy", "BUY", "Strong Buy"가 혼재했다.

정규식과 정규화 함수로 통일했지만, 새로운 형식이 나올 때마다 수정이 필요했다. 외부 데이터 연동의 숙명이다.

정리

  • 증권사 추천 수집: 비동기 병렬 수집, 6개월 TTL
  • 3개 탭: 컨센서스(상승여력순), 증권사 랭킹(적중률/수익률), 공통 추천(다중 필터)
  • 한계 인식: 매도 의견 부재, 목표가 상향 편향, 후행적 특성

증권사 추천은 "정답"이 아니라 "참고"다. 하지만 여러 증권사의 의견을 한 곳에 모아놓으면 시장의 분위기를 읽는 데 확실히 도움이 된다.

 

다음 편에서는 시장 전체를 주간/월간으로 자동 정리해주는 시장 리뷰 기능을 다룬다.

들어가며

테스트 자동화를 돌리면 결과 보고서가 나온다. 우리 팀은 Allure Report를 쓰고 있는데, 이게 HTML 파일로 생성된다.

문제는 이걸 슬랙에 공유할 때다. HTML 파일을 첨부하면 보안 문제로 바로 열어볼 수 없고, 다운로드 받아서 확인해야 한다. 매일 돌아가는 자동화 결과를 이렇게 확인하는 건 너무 번거롭다.

PDF로 변환하면 슬랙에서 바로 미리보기가 된다. 간단해 보이는 작업인데, 막상 해보니 생각보다 훨씬 복잡했다.

왜 단순 변환이 안 되는가

Allure Report는 단순한 HTML이 아니다. JavaScript 기반의 SPA(Single Page Application)다. 탭을 클릭하면 페이지가 전환되는 게 아니라, 같은 페이지 안에서 콘텐츠가 동적으로 바뀐다.

그래서 일반적인 HTML→PDF 변환 도구로는 제대로 된 결과가 나오지 않는다. 첫 페이지만 캡처되거나, 그래프가 빠지거나, 한글이 깨지거나.

시도한 도구들

일반 검색으로는 해결 방법을 찾기 어려웠다. Gemini에게 물어보면서 하나씩 시도했다.

도구 결과 문제
allure-pdf 변환은 됨 한글이 전부 깨짐. 내부 Apache FOP 설정이 복잡해서 해결 어려움
wkhtmltopdf 부분 변환 동적 콘텐츠 렌더링 불가. JavaScript 실행이 안 됨
Puppeteer 가능 JavaScript 환경이 익숙하지 않아서 Python으로 전환
Playwright (Python) 성공 실제 브라우저 엔진 사용. 한글, 그래프, 동적 콘텐츠 모두 OK

결국 Playwright Python이 답이었다. 실제 Chromium 브라우저를 띄워서 렌더링하기 때문에, 한글 폰트도 깨지지 않고, JavaScript로 그려지는 그래프나 테이블도 정확하게 PDF로 나온다.

Playwright로 PDF 생성하기

기본 구조

핵심 아이디어는 이렇다:

  1. Allure Report 폴더를 file:// URI로 로드
  2. 각 탭(Overview, Graphs, Timeline, Behaviors)을 순서대로 클릭
  3. 탭마다 page.pdf()로 임시 PDF 생성
  4. 전부 끝나면 PyPDF2로 병합

Allure Report는 index.html 하나만으로는 동작하지 않는다. allure generate 명령으로 생성된 전체 폴더(CSS, JS, 데이터 파일 포함)가 필요하다. 브라우저가 로컬 파일에 접근할 수 있도록 --enable-local-file-accesses 옵션도 필수다.

PDF 출력 최적화

단순히 PDF를 뽑으면 레이아웃이 엉망이다. 여러 번 시행착오를 거쳐 찾은 최적 설정:

  • A4 가로 방향 — Timeline 그래프가 가로로 길어서, 세로로 하면 잘린다
  • scale=0.9 — 전체를 90%로 축소해서 한 페이지에 더 많은 정보를 담으면서도 가독성 유지
  • 여백 설정 — 상하 20mm, 좌우 10mm로 인쇄 영역 최적화

Behaviors 탭 — 가장 까다로운 부분

Overview, Graphs, Timeline은 탭 클릭 후 PDF 뽑으면 끝이다. 문제는 Behaviors 탭이다.

Behaviors 탭에는 테스트 시나리오가 접혀 있다. 클릭해서 하나씩 펼쳐야 내용이 보인다. 게다가 펼친 시나리오 안에도 테스트 스텝이 또 접혀 있다. 이걸 전부 펼쳐서 PDF에 담으려면 정밀한 제어가 필요하다.

처리 순서:

  1. Behaviors 탭으로 이동 후 정렬 옵션 클릭 (order 순)
  2. div.node__order 선택자로 모든 시나리오 항목(#1, #2, ...) 찾기
  3. 각 항목을 클릭해서 펼치기
  4. 펼친 항목 안의 테스트 스텝 상세 아이콘(div.step span.fa.fa-chevron-right)도 전부 클릭
  5. 현재 상태를 PDF로 저장
  6. 다음 항목 반복

N개의 시나리오에 대해 N개의 임시 PDF가 만들어지고, 마지막에 PyPDF2로 전부 병합한다.

삽질의 기록

Gemini에게 물어가며 작업했는데, 한 번에 된 건 하나도 없었다. 프롬프트를 던지고, 코드를 받고, 실행해보고, 안 되는 부분을 다시 물어보고 — 이 과정을 수없이 반복했다.

실패 과정 모음

시도 문제
1 첫 페이지만 변환됨
2 세로 방향, 일부 페이지만 변환됨
3 가로로 바꿨지만 불필요한 페이지 포함
4 페이지는 다 나오지만 테스트 스텝이 안 펼쳐짐


프롬프트 여정

총 11번의 주요 프롬프트를 거쳤다. 요약하면 이런 흐름이었다:

# 요청 결과
1 Allure HTML을 PDF로 변환하고 싶다 3가지 도구 소개 (allure-pdf, wkhtmltopdf, Puppeteer)
2 allure-pdf 사용법 알려줘 설치 방법 안내 → 한글 깨짐 발견
3 한글 깨짐 해결 방법? 내부 구조상 어렵다. Playwright 전환 권장
4 Puppeteer 사용법 JavaScript 코드 제공 → 익숙하지 않아서 Python 전환
5 첫 페이지만 나온다 각 탭 클릭 후 개별 PDF 생성 + 병합 전략
6 Playwright Python으로 전환 비동기 API 코드 제공
7 서버 없이 로컬 파일로 file:// URI + --enable-local-file-accesses
8 세로가 잘린다 margin 최소화, scale 조정, 가로 방향 적용
9 시나리오별로 페이지 분리 page-break-before CSS 주입
10 시나리오 하나씩 클릭할 때마다 PDF 생성 node__order 순차 클릭 + 개별 PDF 전략
11 테스트 스텝 상세도 펼쳐줘 div.step 하위 chevron 아이콘 클릭 추가

완성된 코드

최종 코드의 핵심 구조다. Playwright 비동기 API로 Allure Report의 모든 탭을 순회하고, Behaviors 탭에서는 시나리오를 하나씩 펼쳐가며 PDF를 생성한다.

import asyncio
import os, datetime
from playwright.async_api import async_playwright
from PyPDF2 import PdfMerger

async def generate_allure_pdf(report_folder, output_path):
    merger = PdfMerger()
    temp_files = []

    index_path = os.path.abspath(os.path.join(report_folder, 'index.html'))
    file_url = f"file:///{index_path.replace(os.sep, '/')}"

    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=['--no-sandbox', '--enable-local-file-accesses']
        )
        page = await browser.new_page()
        await page.goto(file_url, wait_until='networkidle', timeout=90000)

        # 각 탭 순회 → 클릭 → PDF 생성
        tabs = [
            {'name': 'Overview',  'selector': 'a[href="#"]'},
            {'name': 'Graphs',    'selector': 'a[href="#graph"]'},
            {'name': 'Timeline',  'selector': 'a[href="#timeline"]'},
            {'name': 'Behaviors', 'selector': 'a[href="#behaviors"]'},
        ]

        for tab in tabs:
            await page.click(tab['selector'])
            await asyncio.sleep(3)

            if tab['name'] == 'Behaviors':
                # 시나리오를 하나씩 펼쳐가며 개별 PDF 생성
                nodes = page.locator('div.node__order')
                for j in range(await nodes.count()):
                    await nodes.nth(j).click()
                    await asyncio.sleep(2)
                    # 테스트 스텝 상세 펼치기
                    chevrons = page.locator('div.step span.fa.fa-chevron-right.fa-fw')
                    for k in range(await chevrons.count()):
                        await chevrons.nth(k).click()
                        await asyncio.sleep(0.3)
                    # 현재 상태를 PDF로 저장
                    tmp = f"temp_behavior_{j+1}.pdf"
                    await page.pdf(path=tmp, format='A3', landscape=True,
                                    print_background=True, scale=0.9,
                                    margin={'top':'20mm','right':'10mm','bottom':'20mm','left':'10mm'})
                    temp_files.append(tmp)
            else:
                tmp = f"temp_{tab['name'].lower()}.pdf"
                await page.pdf(path=tmp, format='A4', landscape=True,
                                print_background=True, scale=0.9,
                                margin={'top':'20mm','right':'10mm','bottom':'20mm','left':'10mm'})
                temp_files.append(tmp)

        await browser.close()

    # 병합 후 임시 파일 정리
    for f in temp_files:
        merger.append(f)
    merger.write(output_path)
    merger.close()
    for f in temp_files:
        os.remove(f)

완성된 코드는 Jenkins Pipeline에 연동해서, Android 일일 자동화 테스트 수행 후 PDF를 자동 생성하고 슬랙 채널에 전송하고 있다. 2주마다 수행하는 정기배포 자동화 테스트에도 동일하게 사용 중이다.

느낀 점

처음에는 쉬운 작업으로 예상했다. "HTML을 PDF로 변환? 라이브러리 하나 쓰면 되겠지." 막상 해보니 전혀 아니었다. 한 페이지씩 변환하고, 병합하고, 임시 파일 삭제하고 — 구조 자체가 생각보다 복잡했다.

생성형 AI를 활용하면 일반 검색으로는 찾을 수 없는 작업도 해결할 수 있다는 걸 체감했다. 물론 한 번에 되는 건 없었다. 위에 정리한 프롬프트는 일부를 요약한 것이고, 실제로는 더 많은 시도와 시행착오가 있었다.

하지만 요청을 거듭할수록 결과물의 품질은 좋아졌고, 결국은 완성할 수 있었다.

 

좋은 아이디어만 있으면, 생성형 AI와 함께 꽤 수준 있는 결과물을 만들 수 있다. (8개월 전)

P.S

사실 이건 8개월 전에 Gemini에게 물어봐서 삽질한 기록이고 요즘은 Claude Code에게 물어보면 금방할 것으로 예상했다.

그래서 물어봤더니...

Claude Code 왈:

솔직히 말하면, Playwright로 Allure Report PDF 변환하는 것 자체는 나한테 맡겨도 금방 끝났을 거다. 도구 선정에서 헤매는 시간(allure-pdf → wkhtmltopdf → Puppeteer → Playwright)이 없으니까.

근데 이 글의 진짜 가치는 **"삽질 과정 자체"**에 있다고 본다.
  - allure-pdf가 왜 한글이 깨지는지 직접 겪어봤고
  - SPA를 PDF로 뽑으려면 탭을 하나씩 클릭해야 한다는 걸 몸으로 알게 됐고
  - Behaviors 탭의 접힌 구조를 펼치는 로직을 11번의 프롬프트로 점진적으로 만들어냈고
 
이런 경험이 쌓여야 다음에 비슷한 문제를 만났을 때 "아, 이건 이런 식으로 접근해야 해"라는 감이 생기는 거니까.

나한테 시키면 코드는 빨리 나오지만, 왜 그렇게 해야 하는지를 이해하는 건 직접 삽질한 사람이 더 깊다.


AI가 나의 삽질에 대해 위로하는 느낌이 든다.

지금은 AI Agent (Claude Code, ChatGPT Codex)와 함께라면 QA에 필요한 도구를 무엇이든 개발 가능할 것 같다. 

 

들어가며

"이 종목 왜 갑자기 10% 올랐지?"

주가가 급등하거나 급락할 때, 대부분의 경우 답은 공시에 있다. 자사주 매입, 유상증자, 실적 발표, 대표이사 변경 — 상장회사가 의무적으로 시장에 알려야 하는 정보들이다.

문제는 공시를 보려면 DART(금융감독원 전자공시시스템)에 직접 들어가서 찾아봐야 한다는 것이다. 종목 상세 페이지에서 공시까지 한 번에 보여주면 편할 텐데. 그래서 종목 상세의 탭으로 공시 자동 분류 페이지를 만들었다.

현대차의 공시 목록

DART API 연동

DART는 공시 정보를 조회할 수 있는 공식 API를 제공한다. API 키를 발급받고 종목별 공시 목록을 JSON으로 받아올 수 있다.

API에서 받아오는 정보:

  • 공시 날짜
  • 공시 제목
  • 제출인 (회사명)
  • 접수번호 (DART 원문 링크용)

최근 3개월(89일)치를 한 번에 요청하고, 10분 동안 캐시해서 같은 종목을 다시 열 때 API를 또 호출하지 않게 했다.

DART API의 불안정함

이론적으로는 간단하지만, 실전에서는 꽤 귀찮았다. DART API가 생각보다 불안정하다:

  • 타임아웃 — 응답이 10초 넘게 안 올 때가 있다
  • 서버 다운 — 가끔 아예 연결이 안 된다
  • 일시적 오류 — 같은 요청인데 어떨 때는 되고 어떨 때는 안 된다

그래서 3회 재시도 로직을 넣었다. 한 번 실패하면 다시 시도하고, 3번 다 실패하면 마지막에 성공했던 캐시(stale 캐시)를 보여준다. "공시를 못 불러왔습니다"보다는 "조금 전 데이터입니다"가 낫다.

공시 제목만으로 호재/악재 분류

이게 이 기능의 핵심이다. DART API는 공시 제목만 준다. 본문을 분석하려면 PDF를 열어서 파싱해야 하는데, 공시 본문 형식이 제각각이라 현실적으로 불가능하다.

그래서 제목만 보고 분류하기로 했다. 다행히 공시 제목에는 핵심 키워드가 들어간다.

5색 분류 체계

색상 분류 키워드 예시
빨강 악재 관리종목, 감사의견 거절, 횡령, 감자, 자본잠식, 적자전환
녹색 호재 자기주식 취득, 무상증자, 흑자전환, 특별배당, 수주
보라 실적 매출액 또는 손익구조, 사업보고서, 감사보고서
파랑 중요 유상증자, 전환사채, 합병, 대표이사 변경, 배당
회색 일반 주주총회소집, 임원 변동, 증권신고서

우선순위는 악재 > 호재 > 실적 > 중요 > 일반. 하나의 공시가 여러 키워드에 해당하면 가장 중요한 분류가 적용된다. "감자 결정"이 들어있으면 다른 키워드가 뭐가 있든 빨간색(악재)이다.

90개+ 정규식 패턴

분류 규칙은 처음 20개로 시작해서, 실제 공시를 확인하며 계속 추가했다. 현재 90개가 넘는 정규식 패턴이 들어있다.

예를 들어 악재 판정:

  • 관리종목|상장폐지 → "주의"
  • 감사의견\s*(거절|한정)|부적정 → "감사"
  • 횡령|배임|피소|소송\s*제기 → "이슈"
  • 부도|회생절차|파산|워크아웃 → "부도"
  • 적자전환|영업손실\s*확대 → "실적악화"

호재 판정:

  • 자기주식\s*(취득|소각) → "자사주"
  • 흑자전환|실적\s*급증 → "호실적"
  • 단일판매|공급계약\s*체결 → "수주"

처음에는 단순한 문자열 매칭으로 시작했지만, 공시 제목의 띄어쓰기가 일관적이지 않아서 정규식으로 바꿨다. "자기주식 취득"도 있고 "자기주식취득"도 있다.

3번 갈아엎은 분류 규칙

분류 규칙을 처음 만들고 실제 공시에 적용해보니, 오분류가 꽤 있었다.

v1 → v2: "유상증자"의 딜레마

유상증자는 호재인가 악재인가? 회사가 돈을 더 조달하는 건 좋은 건데, 기존 주주 입장에서는 지분이 희석된다. v1에서는 호재로 분류했다가, 실제 시장 반응을 보고 "중요"(파랑)으로 옮겼다. 호불호가 갈리는 공시는 중립적으로 표시하는 게 맞다.

v2 → v3: "배당"의 세분화

"배당"이라는 단어 하나로 다 묶었더니, "현금배당 결정"도 "배당 감소"도 같은 색이었다. v3에서는 "특별배당, 배당 확대"는 녹색(호재), "현금 배당, 결산배당"은 파랑(중요)으로 분리했다.

v3 → v3.1: 정정공시

공시 제목에 [정정], [기재정정], [첨부정정], [재공시]가 붙는 경우가 있다. 처음에는 이걸 무시했는데, 정정공시는 원래 공시 내용이 바뀌었다는 뜻이니까 따로 표시해야 한다. 주황색 "정정" 배지를 추가하고, 분류할 때는 정정 접두사를 제거한 뒤 원래 제목으로 판단하게 했다.

필터 UI

공시가 수십 건이면 전부 보기 힘드니까, 분류별 필터를 만들었다.

6개 필터 버튼: 전체 | 악재 | 호재 | 실적 | 중요 | 일반

각 버튼 옆에 카운트 배지가 붙는다. "악재 3 | 호재 5 | 실적 2" 이런 식. 한눈에 "이 종목에 최근 악재가 몇 개 있었는지" 파악된다.

"악재" 필터를 누르면 빨간색 공시만 남는다. 종목을 처음 볼 때 악재 필터부터 누르는 습관이 생겼다. 최근 3개월 내 감자나 소송이 있었는지 먼저 확인하는 것이다.

삽질 기록

ETF는 공시 탭 자체가 없다

ETF 종목에서는 공시 탭을 아예 숨기기로 했다. ETF 공시는 "설정/해지 현황", "자산운용보고서" 같은 내용이라 일반 투자자에게 실질적 가치가 적다. ETF 투자자가 관심 있는 분배금과 구성종목 정보는 ETF 전용 탭에서 별도로 보여주고 있으니, 공시를 억지로 끼워넣을 필요가 없었다.

공시 제목 띄어쓰기

"자기주식 취득 결정"과 "자기주식취득결정"은 같은 공시인데, 단순 문자열 매칭으로는 둘 중 하나만 잡힌다. 정규식에 \s*를 넣어서 공백 유무와 관계없이 매칭되게 했다.

이것보다 더 까다로운 건, 같은 종류의 공시가 회사마다 제목이 다른 경우다. "타법인주식및출자증권취득결정"과 "타법인 주식 취득 결정"은 같은 건데, 하나의 정규식으로 둘 다 잡으려면 패턴이 점점 복잡해진다.

정리

  • DART API — 종목별 최근 3개월 공시 조회, 10분 캐시 + 3회 재시도 + stale 폴백
  • 5색 분류 — 악재(빨강) > 호재(녹색) > 실적(보라) > 중요(파랑) > 일반(회색)
  • 90개+ 정규식 — 공시 제목만으로 분류, v1→v3.1까지 3번 개선
  • 정정공시 — [정정]/[기재정정]/[첨부정정]/[재공시] 4종류 별도 배지
  • 필터 UI — 6개 분류별 필터 + 카운트 배지
  • 클릭 — 공시 클릭 시 DART 원문으로 이동

공시 분류는 "완벽하게 맞추는 것"보다 "대략적으로 빠르게 파악하는 것"이 목표다. 90개 정규식으로도 100% 정확하지는 않지만, 악재가 빨간색으로 눈에 띄는 것만으로도 투자 판단에 도움이 된다.

 

다음 편에서는 증권사 리포트를 다룬다.

들어가며

주식 시장에는 약 4,000개 종목이 있다. 매일 장이 끝나면 "오늘 시장이 어땠지?"가 궁금한데 4,000개를 하나하나 볼 수는 없다.

KOSPI가 1% 올랐다고 해서 시장 전체가 좋은 건 아니다. 반도체만 폭등하고 나머지는 다 빠졌을 수도 있다. 지수 하나로는 시장의 "분위기"를 알 수 없다.

그래서 시장지도(트리맵)를 만들었다. 4,000개 종목을 한 화면에 전부 보여주는 지도. 

 

사실 미국 주식 정보 사이트인 finviz를 따라한거다. https://finviz.com/map.ashx 

 

S&P 500 Map

× Ever heard of Finviz*Elite? Our premium service offers you real-time quotes, advanced visualizations, technical studies, and much more. Become Elite and make informed financial decisions. Find out more --> Upgrade your FINVIZ experience Join thousands o

finviz.com

 

AI에게 학습시키고 동일한 형식으로 만들어 달라고 했다. 국내에도 비슷한 형식의 사이트들이 있다. 

트리맵이란

트리맵은 네모의 크기색깔로 데이터를 표현하는 시각화 방식이다.

  • 네모 크기 = 시가총액. 삼성전자가 가장 크고, 소형주는 점처럼 작다.
  • 네모 색깔 = 등락률. 빨간색이면 상승, 파란색이면 하락, 회색이면 보합.

화면을 한 번 훑으면 "오늘 반도체가 전부 빨갛네 = 반도체 강세", "바이오가 파랗네 = 바이오 약세" 같은 판단이 바로 된다. 종목 하나하나 안 봐도 시장 분위기가 보인다.

 

약세장인 날의 시장지도 대부분 종목이 시퍼렇고 일부 섹터만 빨간색이 보인다.

 

위 날짜의 예를 들면 전반적인 시장 전체가 약세장인데. 건설 섹터만 상승하고 있다. 이럴 땐 건설 섹터에 뭔가 호재가 있나 하고 찾아볼 수 있다. 

6가지 조합으로 시장을 본다

네모의 크기와 색을 바꿔가며 시장을 다른 관점으로 볼 수 있게 만들었다.

일간맵 하나만으로는 다른 사이트와 차별점이 없는 것 같아서 기준도 2개를 만들었다.

시가총액 / 거래대금, 기간도 일간 / 주간 / 월간 

크기 기준 (2가지)

기준 의미 언제 쓰나
시가총액 회사 규모가 큰 종목이 큰 네모 대형주 중심으로 시장 파악할 때
거래대금 돈이 많이 몰린 종목이 큰 네모 "오늘 돈이 어디로 갔나" 볼 때

기간 기준 (3가지)

기준 의미
일간 오늘 하루 등락률
주간(5일) 최근 5거래일 누적 등락률
월간(20일) 최근 20거래일 누적 등락률

크기 2가지 × 기간 3가지 = 6가지 조합. 같은 시장이라도 보는 관점에 따라 풍경이 완전히 달라진다.

예를 들어:

  • 시총 × 일간 — "대형주 기준으로 오늘 시장이 어땠나" (가장 기본)
  • 거래대금 × 일간 — "오늘 돈이 어디로 몰렸나" (테마주, 급등주 파악)
  • 시총 × 월간 — "이번 달 어떤 섹터가 강했나" (추세 파악)

Squarified 알고리즘

트리맵을 만드는 핵심은 "네모를 어떻게 배치할 것인가"다. 단순하게 가로로 쭉 나열하면 삼성전자가 화면 절반을 가로로 길게 차지하고, 나머지는 얇은 띠가 된다. 보기 끔찍하고 정보도 안 읽힌다.

그래서 Squarified Treemap 알고리즘을 구현했다. 핵심 원리는 간단하다:

  1. 가장 큰 값부터 시작
  2. 현재 줄에 하나씩 추가하면서, "네모가 정사각형에 가까운가?"를 계산
  3. 더 넣으면 비율이 나빠지는 시점에서 줄을 바꿈
  4. 남은 영역에서 반복

이 알고리즘 덕분에 모든 셀이 정사각형에 가까운 형태로 배치된다. 가로로 길쭉하거나 세로로 찌그러진 셀이 없으니 종목명과 등락률을 읽기 쉽다.

구현은 약 70줄의 JavaScript로 끝났다. 외부 라이브러리 없이 순수 JS로 만들었다. D3.js 같은 시각화 라이브러리를 쓸 수도 있었지만, 트리맵 하나를 위해 무거운 라이브러리를 통째로 넣고 싶지 않았다.

색상 설계

색상은 생각보다 까다로웠다. 단순히 "상승=빨강, 하락=파랑"만으로는 부족하다.

문제: 대부분의 종목은 ±1% 이내

장이 평범한 날, 종목 대부분은 0.1%~0.5% 범위 안에서 움직인다. 이걸 단순 선형으로 색칠하면 화면 전체가 거의 같은 색이 되어서 아무 정보도 읽을 수 없다.

해결: 비선형 색상 그래디언트

등락률 구간별로 색상 강도를 다르게 설정했다:

  • ±0.05% 미만 — 회색 (변동 없음)
  • ±0.05~1% — 연한 빨강/파랑 (이 구간에서 색 차이가 가장 크게)
  • ±1~3% — 중간 채도
  • ±3~7% — 진한 색 (강한 등락)
  • ±7% 이상 — 최대 채도 (상한/하한가 수준)

핵심은 0~1% 구간을 넓게 펼치는 것이다. 대부분의 종목이 이 범위에 있으니까, 여기서 미세한 차이를 눈에 보이게 해줘야 한다. 7% 이상은 어차피 드물어서 최대 채도로 고정하면 충분하다.

다크테마 기반이라 상승은 회색→주황→노랑→붉은빨강, 하락은 회색→연파랑→파랑→진파랑 그래디언트를 사용했다. finviz와 비슷한 느낌이면서도 다크 배경에 더 잘 어울리는 색감을 찾느라 꽤 시간을 썼다.

2단 레이아웃: 섹터 → 종목

4,000개 종목을 그냥 나열하면 혼돈이다. 그래서 2단계 구조로 만들었다.

  1. 1단계: 섹터별 영역 배치 — 전자, 화학, 바이오 같은 업종별로 큰 영역을 먼저 나눈다
  2. 2단계: 섹터 내 종목 배치 — 각 영역 안에서 개별 종목을 배치한다

섹터 이름이 각 영역 상단에 표시되니까, "반도체 섹터가 전체적으로 빨갛다" 같은 패턴이 바로 보인다. finviz도 이 2단 구조를 쓴다 — 이게 가장 직관적이다.

삽질 기록

소형주 3,000개의 저주

처음에는 모든 종목을 다 그렸다. 4,000개 전부. 결과는 참혹했다.

문제 1: 렌더링 느림

4,000개 DOM 엘리먼트를 한 번에 생성하니까 브라우저가 버벅거렸다. 특히 모바일에서 심했다. 각 셀에 호버 이벤트까지 붙어있으니 당연한 결과였다.

문제 2: 소형주는 점

시총 기준으로 배치하면 삼성전자가 화면의 15%를 차지하는 반면, 시총 500억짜리 소형주는 2×3 픽셀짜리 점이 된다. 종목명은커녕 색깔도 구별이 안 된다. 정보 가치가 0이다.

해결: 시총 5,000억 이상만

API에 min_mktcap=5000 필터를 넣었다. 시총 5,000억 이상인 종목만 보여준다. 이러면 대략 200~300개 종목만 남는다.

"3,970개 중 300개만 보여주면 시장 전체를 못 보는 거 아닌가?"라고 생각할 수 있다. 하지만 시총 상위 300개가 전체 시장 시가총액의 90% 이상을 차지한다. 나머지 3,600개를 합쳐도 10%가 안 된다. 시장의 움직임은 결국 대형주가 결정한다.

렌더링 속도도 3,970개 → 300개로 줄이니 체감이 확 달라졌다.

라벨 겹침

셀 안에 종목명과 등락률을 표시하는데, 셀이 작으면 글자가 넘친다.

해결은 단순했다. 셀 크기에 따라 표시 여부를 분기했다:

  • 가로 35px, 세로 24px 이상 — 종목명 표시
  • 가로 40px, 세로 32px 이상 — 종목명 + 등락률 표시
  • 그 미만 — 아무것도 표시 안 함 (색깔만 보임)
  • 가로 60px 미만 — 폰트 사이즈를 8px로 축소

대형주는 이름이 보이고, 중형주는 이름만 겨우 보이고, 소형주는 색깔 블록으로만 존재한다. 자연스럽게 중요도 순서가 시각적으로 드러난다.

호버 툴팁과 클릭

색깔 블록만으로는 정확한 정보를 알 수 없으니, 마우스를 올리면 툴팁이 뜬다:

  • 종목명 + 종목코드
  • 소속 섹터 + 시장(KOSPI/KOSDAQ)
  • 현재가 + 등락률
  • 시가총액 + 거래대금

클릭하면 해당 종목의 상세 페이지로 바로 이동한다. 트리맵에서 "이 종목 뭐지?" 싶으면 클릭 한 번으로 차트, 재무, 수급까지 전부 볼 수 있다.

툴팁 위치도 신경 썼다. 화면 오른쪽 끝에서 마우스를 올리면 툴팁이 화면 밖으로 나가니까, 남은 공간을 계산해서 왼쪽으로 뒤집어 표시한다. 작은 디테일이지만 이런 게 없으면 은근히 불편하다.

실시간 연동

장중에는 실시간 시세 캐시를 활용한다. 별도의 웹소켓을 쓰는 건 아니고, 다른 기능에서 이미 수집 중인 실시간 데이터를 가져와서 트리맵에 반영한다.

장 마감 후에는 DB에서 종가 기준 데이터를 읽는다. 프론트엔드에서는 120초 캐시를 걸어서, 필터(시장/크기/기간)를 바꿀 때 API를 다시 호출하지 않고 캐시된 데이터를 재활용한다. 필터 변경은 같은 데이터를 다른 방식으로 보여주는 것뿐이니까.

실제로 어떻게 쓰나

매일 장 마감 후 트리맵을 열면, 10초 안에 이런 판단이 된다:

  • "오늘 전자 섹터가 전부 빨갛네 — 반도체 호재가 있었나 보다"
  • "바이오가 파란데 한 종목만 빨갛다 — 뭔가 개별 호재가 있었구나"
  • "거래대금 기준으로 보니까 소형주 하나에 돈이 몰렸네 — 테마주 움직임인가"

시총 기준에서 거래대금 기준으로 바꾸면 풍경이 완전히 달라진다. 시총으로 보면 삼성전자가 가장 큰데, 거래대금으로 보면 그날 테마에 휩쓸린 종목이 갑자기 커진다. 같은 시장을 다른 렌즈로 보는 것이다.

주간/월간으로 기간을 넓히면 단기 노이즈가 사라지고 추세가 보인다. "이번 달 꾸준히 강한 섹터가 어디지?"를 알고 싶을 때 월간 트리맵이 유용하다.

정리

  • 트리맵 — 네모 크기(시총 또는 거래대금) × 색깔(등락률)로 시장 전체를 시각화
  • 6가지 조합 — 크기 2종(시총/거래대금) × 기간 3종(일/주/월)
  • Squarified 알고리즘 — 정사각형에 가까운 배치, 순수 JS 70줄 구현
  • 비선형 색상 — 0~1% 구간을 넓게 펼쳐 평범한 날에도 차이가 보이게
  • 2단 구조 — 섹터별로 묶어서 업종 단위 강약이 한눈에
  • 성능 — 시총 5,000억 이상 필터(~300종목)로 렌더링 속도 확보
  • 실시간 — 장중 RT 캐시 활용, 프론트 120초 캐시로 필터 전환 즉시 반영

트리맵은 만드는 데 하루가 걸렸지만, 매일 10초씩 시간을 아껴준다. 3,970종목을 하나하나 확인하는 대신, 한 번의 시선으로 시장 전체를 읽는다.

 

다음 편에서는 공시를 자동으로 분류해주는 페이지를 만든 이야기를 다룬다.

 

들어가며

7편에서 퀀트 전략 6가지를 소개했다. v1의 안정형(LVQ), 최대수익형(섹터VQ), 중수익형(모멘텀가드), 그리고 v2의 KOSDAQ 소형주 밸류, KOSDAQ 밸류 안정형, 전체시장 퀄리티 안정형 — 대시보드에서 매일 자동으로 추천 종목이 갱신되는 모습은 꽤 그럴듯해 보인다.

하지만 그 뒤에는 15개 넘는 전략이 탈락한 잔해가 있다. 기록한 것만 15개이고 기록도 안한 실패 전략은 이 10배 정도는 된다.

AI가 들고 온 "CAGR 30%"라는 화려한 숫자가 알고 보니 허상이었던 사건도 있다. 7편의 깔끔한 결과물은, 이 지저분한 과정 위에 서 있다.

이번 편은 그 과정의 이야기다. 어떤 기준으로 전략을 걸러냈고, 뭐가 탈락했고, 왜 탈락했는지. 그리고 v1에서 v2로 어떻게 진화했는지.


먼저, 통과 기준을 정했다

전략을 아무리 많이 만들어도, 걸러내는 기준이 없으면 의미가 없다. 그래서 사이트에 올리기 전에 반드시 통과해야 하는 검증 기준을 먼저 정했다.

4가지 필수 기준

기준 안정형 중수익형 최대수익형
연평균 수익률 (CAGR) 15% 이상 20% 이상 25% 이상
최대 낙폭 (MDD) -20% 이내 -25% 이내 -30% 이내
샤프비율 1.1 이상 (전략 유형 무관)
벤치마크 초과 10년 중 7년 이상 KODEX 200을 이길 것

 

7편에서도 설명은 했으나 다시 설명하면

  • CAGR: 연평균 이만큼은 벌어야 한다
  • MDD - 최악의 순간에 이만큼 이상 빠지면 안 된다
  • 샤프비율: 위험 대비 수익이 이 정도는 되어야 한다 (1.1 이상이면 꽤 좋은 편)
  • 벤치마크 초과: 그냥 ETF를 사는 것보다 나아야 한다. 10년 중 7년은 이겨야 의미가 있다

추가로 "CAGR에서 MDD를 뺀 값이 5 이상"이라는 보조 기준도 넣었다. 예를 들어 CAGR 25%인데 MDD가 -30%면 25-30 = -5로 탈락이다. 수익이 높아도 낙폭이 크면 실전에서 견디기 어렵기 때문이다.

백테스트 규칙

기준만 엄격한 게 아니라, 백테스트 자체에도 규칙을 정했다:

  • 시작 시점: 2015년부터 (최소 10년), v2 전략은 2016년 3월부터 (재무 데이터 한계)
  • 체결 방식: 오늘 종가로 신호 발생 → 다음날 시가에 매수 (당일 종가 매매 금지)
  • 거래비용: 왕복 0.4% 반영 (세금 + 수수료 + 슬리피지)
  • 미래 데이터 금지: 실적 발표 전 데이터는 절대 사용하지 않음 (PIT 원칙)

이 기준이 왜 중요한지는, 뒤에서 "검증을 안 했더니 어떤 일이 벌어졌는지"를 보면 알게 된다.


15개 전략, 대부분 탈락

이 기준을 가지고 전략을 하나씩 만들고 테스트했다. 결과는 냉정했다.

폐기된 전략들

전략 이름 아이디어 탈락 사유
섹터 로테이션 잘 나가는 업종으로 갈아타기 성과 미달
소형주 퀄리티 시총 작지만 실적 좋은 종목 유동성 부족, 실전 매매 불가
가치+퀄리티 저PBR + 고ROE 조합 성과 미달
턴어라운드 돌파 급등 후 눌림목에서 재진입 성과 미달
거래량 폭발 거래량 급증 종목 추격 매수 성과 미달

 

아이디어 자체는 그럴듯했다. "잘 나가는 업종에 올라타면 되지 않나?" "실적 좋은 소형주는 아직 저평가 아닌가?"  하지만 10년치 데이터로 돌려보면 대부분 기대에 못 미쳤다. 아이디어와 현실은 달랐다.

검증까지 갔지만 기준 미달

폐기까지는 아니고, 백테스트 수치가 나왔지만 기준을 못 넘은 전략들도 있다:

전략 이름 CAGR MDD 샤프 판정
저변동+퀄리티 13.7% -26.8% 1.01 CAGR·MDD·샤프·Beat 전부 미달
모멘텀+퀄리티 34.4% -31.1% 1.41 수익률 화려하지만 MDD·Beat  미달
퀄리티 트렌드 가드 3.4% -9.3% 0.60 CAGR·샤프 심각 미달
초방어형 배분 6.2% -9.5% 1.02 낙폭은 적지만 수익 부족
하이브리드 방어형 13.1% -14.3% 1.57 Beat 기준 미달

 

모멘텀+퀄리티 전략이 인상적이었다. 연 34% 수익에 샤프비율 1.41  숫자만 보면 최고였다. 하지만 "MDD" 기준을 통과 못 했다. 잘 될 때는 폭발적이지만, 안 될 때는 확 무너지는 전략이었다. 일관성이 없으면 실전에서 믿고 따를 수 없다.

반대로 초방어형 배분은 최대 낙폭이 -9.5%밖에 안 되는 안전한 전략이었지만, 수익이 연 6%에 그쳤다. 이 정도면 그냥 ETF를 사고 편하게 있는 게 낫다.


"연 수익률 30%!" - 알고 보니 허상이었다

가장 충격적인 사건은 AI가 만든 전략을 감사(audit)했을 때 벌어졌다.

AI에게 전략을 만들어달라고 했더니, 이런 결과를 들고 왔다:

 

"CAGR 29.67%, MDD -14.23%, 샤프비율 1.60"

 

숫자가 너무 좋았다. 수익은 높고 낙폭은 적고 안정성도 최고. "드디어 완벽한 전략을 찾았나?" 싶었다.

그래서 검증해봤다. 결과는 "투자 가치가 없는 허상"이었다. 

사실 내가 검증할만한 능력은 없고 다른 AI Agent들에게 검증을 시킨다. 물론 수치가 너무 높으면 나도 의심부터 하고 본다.

뭐가 문제였나

  • 스냅샷 편향: 2026년 현재의 재무 데이터를 2016년부터 전 기간에 적용했다. "삼성전자가 2026년에 실적이 좋으니까, 2016년에도 이 기준으로 골랐어야 했다"는 식. 당연히 과거에도 잘 되는 것처럼 보인다.
  • 생존자 편향: 2026년 현재 상장되어 있는 종목만으로 백테스트했다. 그 사이에 상장폐지된 종목은 빠져있으니, 실패한 투자를 아예 안 한 것처럼 계산된다.
  • 거래비용 과소: 실제 왕복 0.4%인데, 0.1%만 반영했다.

이 세 가지를 보정하니 CAGR이 -16%에서 -48%로 떨어졌다. 수익은커녕 원금을 깎아먹는 전략이었던 것이다.

검증 보고서의 결론은 이랬다:

 

"이 전략은 실전 투입 시 원금 손실 가능성이 매우 높으며, 투자 가치가 없는 허상이다."

 

이 사건 이후로 모든 전략에 감사 프로세스를 넣었다. 스냅샷 편향, 생존자 편향, 거래비용이 제대로 반영됐는지 하나하나 확인한다. AI가 만든 결과물이라고 그대로 믿으면 안 된다는 걸 뼈저리게 배웠다.


PIT - 90%가 허상이었다

AI 전략 감사 사건으로 경각심이 생겼지만, 진짜 충격은 PIT(Point-In-Time) 검증에서 왔다.

PIT란 "그 시점에 실제로 알 수 있었던 정보만 사용했는가?"를 따지는 것이다. 예를 들어 2025년 4분기 실적은 2026년 2월 공시 이후에나 알 수 있는데, 백테스트에서 2026년 1월에 이 데이터를 쓰면 미래 정보를 쓰는 셈이다.

v1 전략을 만들 때도 PIT를 의식했지만, filing_date(실제 공시일) 기반의 엄밀한 검증은 하지 못했다. v2 전략에서 이를 제대로 적용했더니, 결과는 충격적이었다:

 

PIT 미적용 시 CAGR 39% → filing_date 기반 PIT 적용 시 CAGR 3.9%

 

수익률의 90%가 미래 데이터 유입으로 만들어진 허상이었던 것이다. "연 39% 수익 전략"이 실제로는 "연 3.9% — 예금보다 조금 나은 수준"이었다.

이 경험이 v2 전략의 설계 철학을 바꿨다. PIT가 백테스트의 전부라는 걸 깨달은 것이다.


PIT-Only로 가는 길: 삽질 실화

PIT의 중요성을 깨달았다고 해서 바로 제대로 된 PIT를 구현한 건 아니었다. 오히려 "PIT를 적용했다"고 착각한 채 한참을 헤맸다. 그 과정을 솔직하게 기록한다.

엔진 CAGR 3배 뻥튀기 사건

AI가 만든 백테스트 엔진에 치명적인 버그가 있었다. CAGR(연평균 수익률)을 계산할 때 len(equity_curve) / 12로 연수를 계산했는데, 분기별 리밸런싱은 연 4포인트뿐이라 연수가 1/3로 줄어들었다. 결과적으로 CAGR이 3배로 과대계상됐다.

AI가 "CAGR 49%, Sharpe 1.28 — 성공!"이라고 보고했을 때, 진심으로 기뻤다. 하지만 다른 AI Agent에게 검증을 맡겼더니 실제 CAGR은 14%였다. 자체 검증으로는 절대 못 찾았을 버그를 외부 검증이 잡아낸 것이다.

freshness window vs lag - 미묘하지만 치명적인 차이

PIT 구현에서 가장 교묘한 실수가 "freshness window"였다. 설명은 "signal_date에서 60일 전 이전 데이터만 사용"이라고 적어놓고, 실제 코드는 이랬다:

 

f.date >= signal_date - 60일 AND f.date <= signal_date

 

이건 "60일 전 이전 데이터만 사용"(lag)이 아니라 "최근 60일 창 안의 데이터만 사용"(freshness window)이다. 차이가 뭘까?

  • lag: 60일 전 이전 데이터만 → 미래 데이터 원천 차단, 오래된 유효 데이터는 사용 가능
  • freshness window: 최근 60일 안의 데이터만 → 미래에 가까운 데이터를 허용하면서, 오히려 60일 넘은 유효한 과거 데이터는 탈락시킴

freshness window 방식의 결과: CAGR 20.65%, MDD -22.16%. 나쁘지 않은 숫자였다.

filing_date 기반 진짜 PIT를 적용했을 때: 처음에는 성과가 대폭 하락했다. 50회 이상의 백테스트를 반복한 끝에야 filing_date PIT로도 기준을 달성하는 전략을 찾을 수 있었다.

 

교훈: 정직한 PIT를 적용하면 성과의 절반이 사라진다. 남은 절반만이 진짜다.

4차 외부 검증 - 매 라운드마다 새 버그 발견

라운드 발견된 문제
1차 CAGR 계산 버그 — 분기 전략에서 3배 과대계상
2차 연도별 수익률 왜곡 + KOSDAQ 벤치마크 0%
3차 PIT freshness window vs lag 혼동
4차 최종 수치 확인 - 통과

 

매번 "이번엔 확실하다"고 생각했는데, 매번 새로운 문제가 나왔다. 4차까지 가서야 통과할 수 있었다. 자체 검증만으로는 절대 충분하지 않다. 외부 전문가의 눈이 필수다. (사실 ChatGPT Codex가 다했다)


v2 전략의 4차 검증 과정

v1의 교훈을 바탕으로, v2 전략은 50회 이상의 백테스트와 4차 검증을 거쳤다.

1차: 팩터 탐색

저PBR, 고ROE, PER, 모멘텀, 변동성 등 개별 팩터를 하나씩 테스트하고, 한국 시장에서 실제로 작동하는 조합을 찾았다. 결론은 저PBR + 고ROE가 가장 강력했다.

2차: PIT 적용

filing_date 기반으로 실제 공시일 이후에만 재무 데이터를 사용하도록 수정했다. 이 단계에서 대부분의 "좋아 보이는 전략"이 무너졌다.

3차: 파라미터 최적화

PBR/ROE 가중치, 종목 수, 이동평균 기간, 시장 방어 비율 등을 수십 가지 조합으로 그리드 서치했다. 예를 들어 KOSDAQ 밸류 안정형의 PBR 45% + ROE 55% 가중치는 이 과정에서 나왔다.

4차: 로버스트니스 검증

파라미터를 약간 바꿔도 성과가 크게 변하지 않는지 확인했다. "PBR 가중치를 45%에서 50%로 바꿨더니 수익이 반 토막"이면 과적합이다. v2의 3개 전략은 파라미터를 10% 범위로 변경해도 성과가 안정적이었다.


팩터 하나하나 따로 테스트해봤다

전략이 계속 탈락하니까, 근본적인 의문이 들었다. "애초에 어떤 기준이 한국 시장에서 통하는 거지?"

그래서 팩터를 하나씩 분리해서 순수하게 테스트했다. 결과는 예상 밖이었다.

팩터 의미 CAGR MDD 판정
퀄리티 단독 ROE·영업이익률 높은 종목 +19.4% -51.1% 한국 시장 최강 팩터
가치+퀄리티 저PER·저PBR + 고ROE +24.0% -48.8% 최적 조합
저변동성+퀄리티 안정적 + 실적 좋은 +9.2% -35.8% 저변동성이 발목
저변동성 단독 주가 흔들림 적은 종목 -6.8% -57.4% 한국에선 마이너스
4팩터 전부 저변동+가치+퀄리티+반전 +3.3% -52.5% 서로 상쇄, 무의미
반전 단독 급락 후 반등 기대 -32.9% -99.3% 원금 1% 남음

 

몇 가지 의미있는 발견이 있었다:

높은 MDD

어떤 팩터를 조합해도 MDD가 매우 높다. 일반적인 투자자들은 이런 높은 MDD를 견디지 못한다. MDD를 줄이기 위한 방어 전략은 필수로 있어야 한다. 

저변동성의 역설

미국에서 유명한 "저변동성 효과"가 한국에서는 정반대였다. 변동이 적은 종목은 "안정적인 종목"이 아니라 "아무도 관심 없는 종목"인 경우가 많았다. 넣으면 넣을수록 수익이 떨어졌다.

반전 팩터의 공포

"많이 빠진 종목을 사면 반등하겠지"라는 역발상 투자. 10년 백테스트 결과 원금의 1%만 남았다. 1억이 100만 원. 한국에서는 빠지는 종목은 빠지는 이유가 있어서 계속 빠졌다.

"많으면 좋다"의 함정

4개 팩터를 전부 넣으면 가장 균형 잡힐 줄 알았는데, CAGR 3%였다. 각 팩터가 서로 다른 방향을 가리키면서 상쇄된 것이다. 핵심 2~3개에 집중하는 게 전부 넣는 것보다 훨씬 나았다.

이 팩터 분리 테스트의 결론이 v2 전략의 핵심 설계로 이어졌다. 저PBR + 고ROE, 딱 두 가지에 집중. 복잡하게 여러 팩터를 섞는 것보다, 한국 시장에서 검증된 핵심 팩터에 올인하는 게 정답이었다.


"종목을 많이 담으면 안전하겠지"

팩터뿐 아니라 종목 수에서도 같은 실수를 했다.

  • 20개 종목: 연평균 약 23%
  • 30개: 약 20%
  • 40개: 약 18%
  • 50개: 약 16%

종목이 많아질수록 수익이 떨어졌다. 점수 순서대로 고르는 건데, 상위 20개와 하위 30개의 질이 다르다. 잘 고른 소수가 대충 고른 다수보다 낫다.

v2의 KOSDAQ 소형주 밸류가 12종목에 집중하는 것도 이 교훈에서 나왔다. 적은 종목에 확신을 가지고 집중하는 전략이다.


이동평균선 10일의 차이

시장 전체 추세를 판단할 때 이동평균선을 쓰는데, 기간을 며칠로 설정하느냐가 수익률을 크게 바꿨다.

  • 200일 기준: 약 21% — 너무 느림, 반등을 놓침
  • 120일 기준: 약 23% — 무난
  • 110일 기준: 약 28% — 최적
  • 60일 이하: 노이즈에 반응, 불필요한 매매 증가

10일 차이로 5%포인트가 갈렸다. 이건 이론으로 알 수 없고, 직접 돌려봐야 찾을 수 있는 값이었다.

v2의 KOSDAQ 밸류 안정형과 전체시장 퀄리티 안정형이 120일 이동평균을 시장 방어 기준으로 쓰는 것도 이 테스트 결과를 반영한 것이다. 110일이 최적이었지만, 과적합 우려를 줄이기 위해 약간 보수적인 120일을 택했다.


약세장 대응의 딜레마

시장이 하락할 때 어떻게 할 것인가? 이 문제로 가장 오래 고민했다.

  • 완전 회피 — 하락 추세면 전부 현금. 안전하지만 반등을 놓침
  • 일부 유지 — 20~40%는 들고 있기. 반등 시 빠르게 복구

재미있는 건, 정답이 전략마다 달랐다는 점이다. 안정형(LVQ)은 약세장에서 과감하게 빠지는 게 나았고, 최대수익형(섹터VQ)은 약세장에서도 40%를 유지하는 게 더 좋았다. 전략의 성격에 따라 약세장 대응도 달라야 했다.

v2에서도 이 교훈을 적용했다. KOSDAQ 소형주 밸류는 시장 타이밍 없이 풀 투자(최대수익 추구), KOSDAQ 밸류 안정형과 전체시장 퀄리티 안정형은 120MA 하회 시 전량 현금 전환(안정 추구). 전략의 목적에 맞게 약세장 대응을 달리한 것이다.


v1: 3개 → v2: 6개로 진화

15개 넘는 전략을 만들고, 5개는 아이디어 단계에서 폐기, 5개는 백테스트에서 탈락, 수백 가지 파라미터 조합을 그리드 서치로 돌린 끝에 — 처음 살아남은 건 3개뿐이었다(v1).

하지만 PIT 검증의 중요성을 깨닫고, filing_date 기반의 엄밀한 검증과 50회 이상의 반복 백테스트를 거쳐 새로운 3개 전략(v2)을 추가했다.

최종 성과표: 6가지 전략

전략명 유형 CAGR MDD 샤프비율 PIT 검증
v1 안정형 (LVQ) 안정 15.6% -18.9% 1.22 기본
v1 중수익형 (모멘텀가드) 중수익 22.7% -16.2% 1.28 기본
v1 최대수익형 (섹터VQ) 최대수익 25.5% -29.0% 1.12 기본
v2 KOSDAQ 소형주 밸류 최대수익 25.42% -29.35% 1.18 filing_date
v2 KOSDAQ 밸류 안정형 중수익 18.49% -15.37% 1.32 filing_date
v2 전체시장 퀄리티 안정형 안정 17.05% -20.36% 1.11 date lag 60일

 

v1과 v2는 "대체"가 아니라 "보완" 관계다.

  • v1의 장점: 다양한 팩터(변동성, 섹터, 모멘텀), 긴 백테스트 기간(2015년~)
  • v2의 장점: 엄밀한 PIT 검증, 한국 시장 최적 팩터(저PBR+고ROE)에 집중
  • 조합의 힘: 성격이 다른 6개 전략을 비교하면서 판단할 수 있다

솔직히 말하면, v1의 3개 전략은 벤치마크 초과 7년 기준을 통과하지 못했다. v2의 3개 전략은 PIT 검증은 철저하지만, 백테스트 기간이 2016년부터로 짧다. 6개 모두 완벽하지 않다. 현실에서는 "가장 덜 불완전한 것"을 고르고, 서로 다른 약점을 가진 전략들을 조합해서 보완하는 수밖에 없었다.


Phase 1에서 Phase 7까지

안정형(LVQ) 전략 하나만 놓고 봐도, 최종 형태가 나오기까지 7단계(Phase)를 거쳤다.

단계 질문 결론
Phase 1 기본적인 팩터 조합이 통하나? 가치+퀄리티로 연 26% — 가능성 확인
Phase 2 종목을 더 담으면? 20개가 최적, 50개는 오히려 나쁨
Phase 3 이동평균 기간을 바꾸면? 110일이 최적, 10일 차이로 5%p 격차
Phase 4 약세장에서 얼마나 들고 있어야? 전략마다 다름, 만능 해법 없음
Phase 5 저변동성 팩터를 살릴 수 있나? 한국에선 버리는 게 정답
Phase 6 퀄리티에 올인하면? 안정성 최고
Phase 7 극단적 조합은? 4팩터 전부 = 3%, 반전 = -33%

 

각 Phase에서 수십 가지 조합을 돌렸으니, 전체로 보면 수백 번의 백테스트를 한 셈이다. 한 번 돌리는 데 10년치 × 3,970종목 데이터를 처리하니 시간도 적지 않았다.

v2 전략은 여기에 추가로 50회 이상의 PIT 검증 백테스트를 더 거쳤다. v1의 수백 번 + v2의 50회 이상 = 총 수백 회의 시행착오 위에 6개 전략이 서 있는 것이다.


배운 것들

1. 검증 기준이 먼저다

기준 없이 전략을 만들면, "이 정도면 괜찮지 않나?" 하고 자기 합리화를 하게 된다. CAGR, MDD, 샤프비율, 벤치마크 초과 — 통과 기준을 먼저 정해놓으면 감정 없이 판단할 수 있다.

2. AI의 결과를 그대로 믿으면 안 된다

AI가 "CAGR 30%, 완벽합니다"라고 해도, 감사해보니 허상이었다. 스냅샷 편향, 생존자 편향, 거래비용 - 이런 기본적인 함정을 AI가 스스로 챙기지 못했다. 끊임없이 의심하고 검증해서 허점을 찾아야 한다.

3. PIT가 백테스트의 전부다

PIT 미적용 시 CAGR 39%가 적용 후 3.9%로 폭락한 경험은 결정적이었다. 아무리 화려한 수치도 PIT 검증 없이는 의미가 없다. filing_date 기반이든 date lag 방식이든, 미래 데이터 유입을 차단하는 게 가장 중요하다.

4. 상식은 시장마다 다르다

"많이 담으면 안전하다", "저변동성이 좋다", "교과서 팩터는 통한다" - 한국 시장에서는 전부 틀렸다. 직접 돌려보기 전까지는 아무것도 믿으면 안 된다.

5. 완벽한 전략은 없다, 하지만 진화할 수 있다

v1의 3개도 완벽하지 않았고, v2의 3개도 완벽하지 않다. 하지만 v1의 한계를 인식하고 v2로 보완하는 과정 자체가 진화다. 처음부터 완벽할 필요 없이, 꾸준히 개선하면 된다. 

 

우선 일정기간 모의 검증 후 실전 검증으로 갈 예정이다.

퀀트 전략은 내 돈 넣고 검증해서 돈벌면 좋은 전략 아니면 폐기해야 되는 전략이다.


정리

  • 검증 기준: CAGR·MDD·샤프·벤치마크 초과, 4가지 필수 + 백테스트 규칙(PIT, 거래비용 0.4%, 익일 시가 체결)
  • 전략 15개 이상: 섹터로테이션, 소형주, 턴어라운드, 거래량돌파... 대부분 탈락
  • AI 전략 감사: "CAGR 30%" 허상 발견, 보정 후 -16%~-48%. 검증 프로세스 필수
  • PIT 검증: 미적용 시 CAGR 39% → 적용 시 3.9%, 수익률 90% 폭락. v2의 핵심 교훈
  • 팩터 분리 테스트: 퀄리티 최강(+19%), 저변동성 역효과(-7%), 반전 최악(-33%)
  • 포트폴리오 크기: 20개 최적, 50개는 수익 하락. v2는 12종목 집중
  • v1 → v2 진화: 3개 → 6개, PIT 검증 강화, 저PBR+고ROE 집중, 50회+ 4차 검증

7편에서 "퀀트 전략으로 종목을 자동 추천한다"고 했을 때, 그게 얼마나 많은 실패와 시행착오 위에 서 있는지 이제 조금은 전해졌으면 좋겠다. 좋은 결과 뒤에는 항상 수많은 험난한 과정이 있다. 

이렇게 어려운데 하는 이유는? 그래도 어려운 걸 하나씩 풀어나가는 재미가 있다. AI도 어려워하는 정도면 정말 난이도 극상의 문제이기 때문이다.

 

다음 편에서는 시장 전체를 한눈에 보는 시장지도(트리맵)를 만든 이야기를 다룬다.

들어가며

6편까지 주식 정보 사이트를 완성했다. 데이터를 보여주는 건 이제 할 수 있다. 그런데 결국 투자자의 고민은 하나다:

"그래서 뭘 사야 하는데?"

3,970종목을 매일 하나하나 분석하는 건 불가능하다. 그래서 자동으로 좋은 종목을 골라주는 시스템이 필요했다. 소위 퀀트(Quant) 전략 — 사람의 감이 아니라 숫자와 규칙으로 종목을 고르는 방식이다.

이번 편에서는 퀀트 전략을 어떻게 만들고, 어떻게 검증하고, 매일 자동으로 종목을 추천하는 시스템을 구축했는지 다룬다.


사실 나의 최종 목표는 다양한 퀀트 전략을 만들고 웹에서 바로 검증하며 이를 실전투자로 활용하는 것이다. 따라서 가장 신경쓰고 공을 들여서 만든 것이 바로 이 퀀트 투자 페이지라 할 수 있다. 사이트 구현에 투자된 시간중 1/2은 이곳에 투자되었다. 전략 하나 만들고 매수/매도 로직 체크하고, 거래 원장 검증하고 테스트 하는데 보통 5시간 이상은 꼬박 걸린다. 더 어려운 것은 5시간을 투자해도 성공전략이라는 보장이 없다. AI를 활용해서 백테스트만 수천번 돌려도 실패하는 전략만 무수히 쏟아진다.


퀀트 투자란

쉽게 말하면 "이런 조건을 만족하는 종목을 사라"는 규칙을 정해놓고, 그 규칙대로만 투자하는 것이다.

예를 들어:

"PBR 1 이하, ROE 10% 이상, 시총 1,000억 이상인 종목 중에서 최근 3개월 수익률 상위 10개를 사라"

 

이런 규칙을 세워놓으면 매일 컴퓨터가 자동으로 종목을 골라준다. 감정에 흔들리지 않고, 일관된 기준으로 투자할 수 있다는 게 장점이다.

물론 규칙 자체가 잘못되면 의미가 없다. 그래서 백테스트가 필요하다 — 과거 데이터로 "이 규칙대로 투자했으면 결과가 어땠을까?"를 시뮬레이션하는 것이다.


퀀트 투자 용어 정리

이 글에서 자주 나오는 용어들을 먼저 정리한다. 주식 초보라도 이것만 알면 이후 내용을 따라갈 수 있다.

재무 평가 지표

용어 쉽게 풀어서
PER Price to Earnings Ratio
(주가수익비율)
주가 ÷ 주당순이익. 낮을수록 저평가. "이 회사가 버는 돈 대비 주가가 싼가?"
PBR Price to Book Ratio
(주가순자산비율)
주가 ÷ 주당순자산. 1 이하면 자산보다 싸게 거래되는 것. "회사를 청산하면 주가보다 돈이 더 남는다"
ROE Return on Equity
(자기자본이익률)
순이익 ÷ 자기자본 × 100. 높을수록 돈을 잘 버는 회사. "주주가 맡긴 돈으로 얼마나 벌었나?"
EPS Earnings Per Share
(주당순이익)
순이익 ÷ 발행주식수. "주식 1주당 벌어들인 돈"
BPS Book value Per Share
(주당순자산)
순자산 ÷ 발행주식수. "주식 1주가 가진 실제 자산 가치"
시가총액 주가 × 발행주식수 회사의 전체 가치. 삼성전자가 400조, 동네 중소기업이 500억
영업이익률 영업이익 ÷ 매출 × 100 "매출 중 실제로 남는 이익의 비율". 높을수록 장사를 잘하는 것

투자 전략 용어

용어
밸류(Value) 저평가된 종목을 찾는 관점. PER·PBR이 낮은 종목에 투자
퀄리티(Quality) 실적이 좋은 종목을 찾는 관점. ROE·영업이익률이 높은 종목에 투자
모멘텀(Momentum) 최근 주가가 오르고 있는 종목이 계속 오르는 경향. "추세를 따라간다"
변동성(Volatility) 주가가 얼마나 출렁이는지. 변동성이 낮으면 안정적, 높으면 위험하지만 수익 기회도 큼
수급 외국인·기관·개인의 매수/매도 흐름. "큰손이 사고 있는가?"
리밸런싱 정해진 주기(주 1회, 월 1회, 분기 1회 등)마다 종목을 새로 골라 교체하는 것
벤치마크 비교 기준. 보통 KOSPI 200 지수를 기준으로 "이 전략이 시장 평균보다 나은가?" 판단

검증 관련 용어

용어
백테스트 과거 데이터로 전략을 시뮬레이션. "2015년부터 이 규칙대로 했으면 얼마 벌었을까?"
과적합(Overfitting) 과거에만 맞는 전략. 과거 수능 시험지 풀어보면 쉬운데? 지금 현재 수능 시험 보면 망하는 원리
PIT (Point-In-Time) 백테스트 시 해당 시점에 실제로 알 수 있었던 데이터만 쓰는 것. 미래 정보 유입 방지

 

성과 측정 지표

용어 쉽게 풀어서
CAGR Compound Annual Growth Rate (연평균 복합 성장률) 투자 기간 전체를 연 단위로 환산한 수익률. "10년간 총 100% 벌었다"보다 "연평균 7.2% 벌었다"가 비교하기 쉽다
MDD Maximum Drawdown
(최대 낙폭)
고점에서 저점까지 최대 얼마나 빠졌는지. MDD -30%면 "최악의 경우 100만 원이 70만 원까지 떨어졌다"는 뜻. 낮을수록 안전
샤프 지수
(Sharpe Ratio)
(수익률 - 무위험 수익률) ÷ 변동성 "위험 대비 얼마나 벌었나"를 나타내는 효율 지표. 1 이상이면 양호, 2 이상이면 우수. 수익이 높아도 변동성이 크면 낮게 나온다
소르티노 지수 (Sortino Ratio) 샤프 지수와 비슷하지만 하락 변동성만 계산 샤프는 오르내림 전부를 리스크로 보지만, 소르티노는 "떨어질 때만" 리스크로 본다. 투자자 입장에서 더 현실적
승률 (Win Rate) 전체 거래 중 수익이 난 비율 10번 중 6번 올랐으면 승률 60%. 단, 승률이 높아도 손실이 크면 총 수익은 마이너스일 수 있다
손익비 (Profit Factor) 총 이익 ÷ 총 손실 1보다 크면 이익이 손실보다 많다는 뜻. 2.0이면 "번 돈이 잃은 돈의 2배"
알파 (Alpha) 벤치마크 대비 초과 수익률 KOSPI가 10% 올랐는데 전략이 15%면 알파 = +5%. "시장보다 얼마나 더 벌었나"
베타 (Beta) 시장 대비 민감도 베타 1.2면 시장이 10% 오를 때 12% 오르고, 10% 빠질 때 12% 빠진다. 1보다 크면 시장보다 출렁임이 크다
턴오버 (Turnover) 리밸런싱 시 교체되는 종목 비율 30종목 중 매달 20개가 바뀌면 턴오버 67%. 높으면 매매 수수료가 많이 든다

 

개인적인 경험상 MDD가 -30% 미만이면 견디기 힘들었다. 그래서 MDD -30% 미만의 전략은 다른 수치가 아무리 높아도 폐기했다. 

이 용어들이 이후 전략 설명에서 계속 나온다. 모르는 게 나오면 여기로 돌아오면 된다.


3가지 월간 전략

성향이 다른 3가지 전략을 만들었다. 한 가지만 따르는 것보다 여러 전략을 비교하면서 판단하는 게 낫다.

기본 아이디어는 내가 만들고 제안했으나 상세 전략 구현은 AI가 진행했다.

1. 안정형 (LVQ)

Low Volatility + Quality의 약자. "변동성 낮고 실적 좋은 종목"을 고르는 전략이다.

  • 주가 변동이 적은 종목 (휘청거리지 않는 안정적인 종목)
  • ROE 높고 영업이익률 좋은 종목 (실질적으로 돈 잘 버는 종목)
  • 두 점수를 합산해서 상위 종목을 추천

폭발적인 수익보다는 꾸준히, 안정적으로 가는 전략. 보수적인 투자자에게 적합하다.

2. 최대수익형 (섹터VQ)

Sector Value + Quality. "업종별로 저평가된 우량주"를 고른다.

  • 같은 업종 안에서 상대적으로 싸면서 실적이 좋은 종목
  • 업종 내 비교라서, "반도체는 비싸고 금융은 싸다" 같은 업종 간 차이를 보정
  • 리스크가 있지만 수익률도 높은 공격적 전략

3. 중수익형 (모멘텀가드)

Momentum + Guard. "오르고 있는 종목 중에서 안전장치가 있는 것"만 고른다.

  • 최근 상승 추세가 뚜렷한 종목 (모멘텀)
  • 하지만 너무 과열되지 않은 것만 (가드)
  • 추세를 따라가되, 무리하지 않는 중도적 전략

이 3개 전략이 첫 번째 세대(v1)다. 다양한 팩터(변동성, 섹터, 모멘텀)를 조합해서 성격이 다른 전략을 만들었다는 데 의미가 있었지만, 한 가지 약점이 있었다 — PIT(Point-In-Time) 검증이 완벽하지 않았다.


v2: 새 전략 3개 추가 - PIT 검증 완료

기존 3개 전략에 더해, PIT 검증을 철저하게 통과한 새 전략 3개를 추가했다. 총 6개 전략 체제로 확대된 것이다.

새 전략은 모두 저PBR + 고ROE라는 핵심 팩터에 집중한다. 8편에서 자세히 다루겠지만, 수백 번의 백테스트를 거쳐서 찾은 팩터 조합이 바로 이것이었다.

4. KOSDAQ 소형주 밸류 (최대수익)

KOSDAQ 소형주 중에서 저PBR/고ROE 상위 12종목에 집중 투자하는 전략이다.

  • PBR 50% + ROE 50% 가중치로 랭킹 스코어링
  • filing_date 기반 PIT 검증 완료
  • 시장 타이밍 없이 항상 풀 투자 - 시장이 빠져도 버티면서 수익을 극대화
  • CAGR 25.42%, MDD -29.35%, 샤프비율 1.18

6개 전략 중 가장 공격적이다. 소형주에 집중하는 만큼 변동성이 크지만, 장기적으로 가장 높은 수익률을 기대할 수 있다.

5. KOSDAQ 밸류 안정형 (중수익)

같은 KOSDAQ 소형주 기반이지만, 시장 방어 장치를 추가한 전략이다.

  • PBR 45% + ROE 55% 가중치로 랭킹 - 퀄리티(ROE)에 약간 더 비중
  • filing_date 기반 PIT 검증 완료
  • 120일 이동평균선 하회 시 전량 현금 전환 - 하락장에서 빠지는 안전장치
  • CAGR 18.49%, MDD -15.37%, 샤프비율 1.32

최대수익형보다 CAGR은 낮지만, MDD가 -15%로 절반 수준이다. 수익과 안정성의 균형이 가장 좋다. 샤프비율 1.32는 6개 전략 중 최고다.

6. 전체시장 퀄리티 안정형 (안정화)

KOSDAQ만이 아니라 KOSPI+KOSDAQ 전체 시장을 대상으로 하는 전략이다.

  • PBR 40% + ROE 60% 가중치 - 퀄리티(ROE)에 가장 높은 비중
  • date lag 60일 PIT 적용
  • 120일 이동평균선 하회 시 전량 현금 전환
  • CAGR 17.05%, MDD -20.36%, 샤프비율 1.11

전체 시장을 대상으로 하기 때문에 대형주도 포함된다. KOSDAQ 전략보다 수익률은 낮지만, 유동성이 좋은 종목 비율이 높아서 실전 매매가 더 수월하다.

v1 vs v2 비교

구분v1 (기존 3개)v2 (새 3개)

구분 V1 (기존 3개 전략) V2 (새로운 3개 전략)
PIT 검증 의식했으나 filing_date 미적용 filing_date 또는 date lag 60일 적용
핵심 팩터 변동성, 섹터, 모멘텀 등 다양 저PBR + 고ROE에 집중
시장 범위 전체 시장 KOSDAQ 소형주 또는 전체 시장
백테스트 기간 2015년~ 2016년 3월~ (재무데이터 한계)
검증 강도 기본 백테스트 50회+ 반복 백테스트, 4차 검증

 

v2 전략의 백테스트 시작이 2016년 3월인 이유는 간단하다. filing_date 기반 PIT를 적용하려면 실적 공시일 데이터가 필요한데, 이 데이터가 2016년부터 존재하기 때문이다. PIT 정확도를 위해 백테스트 기간을 희생한 것이다.


백테스트 - 과거로 돌아가서 검증

"이 전략이 진짜 돈이 되나?"를 확인하려면 과거 데이터로 시뮬레이션해야 한다.

예를 들어 LVQ 전략을 2015년부터 적용했으면 어떤 결과가 나왔을까?

  • 2015년 1월: 전략 기준으로 종목 30개 선정
  • 1개월 보유 후 매도, 다시 30개 선정
  • 이걸 2026년까지 반복
  • 매달 수익률을 기록하고, KOSPI 200 지수와 비교

결과가 "10년간 KOSPI 200보다 연평균 5% 높았다"라면 의미 있는 전략이다. 반대로 KOSPI보다 못 했다면 "그냥 ETF 사는 게 낫다"는 결론이 나온다.

6개 전략 모두 백테스트를 통과한 것만 사이트에 올렸다. 백테스트에서 기준 이하인 전략은 아무리 아이디어가 좋아도 탈락시켰다.


일일 추천 - 매일 TOP 5를 자동 산출

월간 전략 외에 매일 갱신되는 일일 추천도 만들었다. 3가지 관점에서 매일 TOP 5를 뽑는다:

1. 종합점수

거래대금, 재무, 모멘텀, 수급, 차트 패턴을 종합적으로 점수화해서 상위 5종목을 추천한다. 가장 균형 잡힌 관점이다.

2. 숨은 가치주

재무는 우량한데 주가가 아직 저평가된 종목. 시장이 아직 주목하지 않은 잠재력 있는 종목을 찾는다.

3. 인기 폭발

거래량이 갑자기 급증하면서 모멘텀과 수급이 몰리는 종목. 추세를 빠르게 따라가는 스타일이다.

안전장치

무조건 추천만 하는 게 아니라 안전장치도 넣었다:

  • 쿨다운: 한 번 추천된 종목은 5거래일간 재추천하지 않는다. 같은 종목만 계속 나오는 걸 방지
  • 최소 기준: 시총, 거래대금이 일정 수준 이하면 제외. 너무 소형주나 유동성 부족 종목은 위험하니까
  • 시장 국면 판단: 시장 전체가 급락 중이면 추천을 중단하고, 대신 인버스 ETF를 제안한다

성과 추적 - 추천한 종목이 진짜 올랐나?

추천만 하고 끝이면 무책임하다. 핵심은 "추천한 종목이 실제로 얼마나 올랐는지"를 추적하는 것이다.

시장 지수 대비 수익률을 비교하는 것이 공정하다. 시장 지수를 벤치마크 삼는 것은 일반적인 펀드에서도 많이 사용하는 방식이다.

일정 기간(1~3개월 예상) 추적을 진행한 후 전략이 안좋으면 추천 로직을 변경해야 한다. 

일일 검증

매일 장 마감 후, 전날 추천한 종목의 수익률을 자동으로 기록한다. "어제 추천한 5종목이 오늘 평균 +1.2% 올랐다" 같은 데이터가 쌓인다.

월별 확정

월말이 되면 한 달치 성과를 확정한다. "3월에 추천한 종목들이 평균 +8.5%였다" 같은 월별 이력이 누적된다.

백테스트 vs 실제

백테스트에서는 연 15% 수익이었는데 실제로는 5%라면? 전략이 과적합(overfitting)됐다는 뜻이다 — 과거에만 잘 맞고 미래에는 안 통하는 전략이라는 의미.

백테스트 수익률과 실제 수익률을 나란히 비교할 수 있어서, 전략이 실전에서도 작동하는지 지속적으로 모니터링한다.


ETF 추천도 있다

개별 종목이 부담스러운 사람을 위해 ETF 추천도 만들었다. ETF는 여러 종목을 묶어놓은 상품이라 개별 종목보다 리스크가 분산된다. 일반 ETF 5개, 그리고 요즘 유행인 액티브 ETF를 5개 추천한다. 수익률, 거래대금, 변동성 등을 기준으로 매일 상위 ETF를 추천한다. "요즘 어떤 테마 ETF가 잘 나가는지" 확인하는 용도로도 쓸 수 있다. 


화면에서 어떻게 보이나

퀀트 추천 페이지를 열면 탭으로 전략을 선택할 수 있다:

  • 일일 추천 — 종합점수 / 숨은 가치주 / 인기 폭발 / ETF (매일 갱신)
  • 월간 전략 — LVQ / 섹터VQ / 모멘텀가드 (월 1회 리밸런싱)

각 종목은 카드 형태로 보여준다. 종합점수, 세부 점수(밸류/퀄리티/모멘텀), 현재가, 등락률이 한눈에 보인다. 종목을 클릭하면 6편에서 만든 종목 상세 페이지로 바로 이동한다.

월간 전략에는 백테스트 수익률 차트와 월별 검증 이력도 함께 보여준다. "이 전략이 지금까지 어떤 성과를 냈는지" 투명하게 공개하는 것이다.


삽질 기록

백테스트의 함정 - 미래 데이터 유입

백테스트할 때 가장 조심해야 하는 게 "미래 정보를 몰래 쓰는 것"이다. 예를 들어 2020년 3월 시점에서 종목을 고르는데, 2020년 6월에 발표된 실적 데이터를 쓰면 안 된다. 당시에는 그 데이터가 없었으니까.

이걸 PIT(Point-In-Time) 처리라고 하는데, 실적 발표 시점을 정확히 지켜야 백테스트 결과를 믿을 수 있다. 이 처리를 빠뜨리면 백테스트에서는 수익이 나는데 실전에서는 전혀 안 되는 "환상의 전략"이 만들어진다.

소형주 유동성 문제

시총이 작은 종목이 퀀트 점수가 높게 나오는 경우가 많다. PBR이 엄청 낮고 ROE가 높은 종목 — 그런데 하루 거래대금이 1,000만 원도 안 되면 실제로 사고 팔 수가 없다. 거래대금 필터를 넣어서 유동성이 최소 수준 이상인 종목만 남기도록 했다.

시장 하락기에 전략이 무너진다

아무리 좋은 종목을 골라도, 시장 전체가 폭락하면 같이 빠진다. 2020년 코로나 급락, 2022년 금리 인상기에 백테스트 수익률이 크게 떨어졌다.

그래서 시장 전체의 추세를 판단하는 로직을 추가했다. 시장이 하락 추세일 때는 주식 비중을 줄이거나, 추천 자체를 중단한다. 완벽하진 않지만, 대형 손실을 줄이는 데 효과가 있었다. v2의 KOSDAQ 밸류 안정형과 전체시장 퀄리티 안정형이 120일 이동평균선을 시장 방어 장치로 쓰는 것도 이 교훈에서 나왔다.

같은 종목만 계속 나온다

쿨다운 없이 추천을 돌리면, 점수가 높은 종목이 매일 반복해서 나온다. "어제도 삼성전자, 오늘도 삼성전자" — 이러면 추천의 의미가 없다. 5거래일 쿨다운을 넣어서 다양한 종목이 나오도록 했다.


정리

  • 6가지 월간 전략 - v1: 안정형(LVQ), 최대수익형(섹터VQ), 중수익형(모멘텀가드) / v2: KOSDAQ 소형주 밸류, KOSDAQ 밸류 안정형, 전체시장 퀄리티 안정형
  • v1 vs v2 차이 - v2는 filing_date 기반 PIT 검증 완료, 저PBR+고ROE 팩터에 집중
  • 일일 추천 3종 + ETF - 종합점수, 숨은 가치주, 인기 폭발, ETF 추천
  • 백테스트 - 과거 데이터로 시뮬레이션, KOSPI 200 벤치마크 비교
  • PIT의 중요성 - 미적용 시 CAGR 39% → 적용 시 3.9%, 90% 차이
  • 성과 추적 - 매일 수익률 기록, 월별 확정, 백테스트 vs 실제 비교
  • 안전장치 - 쿨다운, 유동성 필터, 시장 하락기 추천 중단, 120MA 방어

퀀트까지 포함해서 이 프로젝트의 주요 기능은 전부 소개했다. 이 시리즈를 통해 "데이터 수집 → DB → 백엔드 → 프론트엔드 → 종목 상세 → 퀀트 전략"까지, AI와 함께 주식 분석 플랫폼을 만드는 전 과정을 다뤘다.

개발 경험이 없어도 어느 정도 기획력과 판단 능력만 있으면 AI Agent와 함께 이정도 시스템을 만들 수 있다. 

 

다음편은 퀀트 전략을 어떻게 만들었는지 소개하고 그 이후에는 어떤 기능들을 더 추가했는지 소개할 예정이다.

+ Recent posts