카테고리 없음

주식 자동매매 섹터 집중 리스크 — 건설주 4개 담았다가 하루에 40만원 잃은 이유

우베르 2026. 5. 16. 09:00
반응형

 

직장인 자동매매 시리즈 · 3편

주식 자동매매 섹터 집중 리스크 —
건설주 4개 담았다가 하루에 40만원 잃은 이유

← 2편 읽기: 터틀트레이딩 파이썬 코드 공개 — 55일 신고가 돌파 자동매매 직접 구현

 

주식 자동매매를 시작하면서 분산투자가 중요하다는 건 알고 있었어요. 그런데 알고 있는 것과 코드에 규칙으로 박아두는 것은 전혀 다른 얘기였습니다.

5월 4일 아침, 알림이 연달아 울렸어요.

DL이앤씨 손절. 삼성E&A 손절. GS건설 손절.

세 개가 같은 날 터졌습니다. 그날 하루 손실 -404,781원. 멍하니 로그를 들여다보다가 한 가지 사실이 눈에 들어왔어요.

"세 종목이 전부 건설주였다."

거슬러 올라가보니 더 황당했어요. 삼성E&A, GS건설, 대우건설, DL이앤씨. 한 달 내내 포트폴리오의 67~100%가 건설주였더라고요. 봇이 잘못한 게 아니었습니다. 봇은 점수 높은 종목을 순서대로 담았을 뿐이에요. 잘못은 "같은 테마를 너무 많이 담지 말 것"이라는 규칙을 코드에 넣지 않은 저한테 있었습니다.

이 글은 그 실수를 인정하고, 섹터 집중 리스크를 코드로 해결한 과정을 담았어요. 왜 같은 테마가 함께 무너지는지, 어떻게 제한 로직을 구현했는지, 그리고 그 로직이 있었다면 5월 4일이 달랐을지까지 순서대로 풀어드릴게요.

데이터를 열어봤더니 — 23일간 포트폴리오의 80%가 건설주였다

데이터 분석

막연히 "건설주가 많았다"는 느낌은 있었어요. 그런데 실제로 날짜별로 뽑아보니 생각보다 훨씬 심각했습니다.

날짜별 포트폴리오 건설주 비중 (실제 데이터)
04-08
 
100%
04-09
 
100%
04-10
 
80%
04-16
 
67%
04-20
 
80%
05-01
 
80%
05-04
 
25%

손절 당일(05-04)에야 건설 비중이 25%로 내려왔다 — 이미 손실이 난 뒤

4월 8일 시작 첫날부터 건설주 100%였어요. 삼성E&A와 GS건설, 둘 다 건설이었으니까요. 그 뒤로도 대우건설, DL이앤씨가 잇달아 진입하면서 4월 9일부터 5월 1일까지 23일 연속으로 포트폴리오의 67~100%가 건설주였습니다. 딱 하루, 4월 15일만 대한광통신 한 종목만 남았던 날이 예외였어요.

그리고 GS건설에는 따로 짚어둘 부분이 있습니다. 4월 8일 13주로 시작한 포지션이 4월 20일에 갑자기 104주로 늘어 있었어요. 피라미딩이 여러 번 실행되면서 결과적으로 GS건설 한 종목에만 104주 × 38,356원 ≈ 399만 원이 묶였습니다. 전체 계좌의 약 40%가 건설주 한 종목에 집중된 셈이에요.

23일 건설 비중 67%+ 유지 기간
104주 GS건설 최대 보유량
-404,781원 5월 4일 단일 일 손실

원인 분석 — 분산투자 중요한 거 몰랐던 게 아니다

원인

억울하게도, 분산투자가 중요하다는 건 알고 있었어요. 그런데 알고 있는 것과 코드에 규칙으로 박아두는 것은 전혀 다른 얘기더라고요.

봇이 건설주를 골라온 건 논리적으로 맞는 행동이었어요. 2편에서 설명했듯이 봇은 100점 만점 Scoring으로 종목을 고릅니다. 4월 초 건설 섹터가 강세였고, 건설주들이 줄줄이 70점을 넘었어요. 봇 입장에서는 점수 순서대로 담은 것뿐입니다. 봇은 "이미 건설이 4개나 있잖아"라는 생각을 할 수 없어요. Scoring 로직이 개별 종목 점수만 계산할 뿐, 테마라는 개념을 모르기 때문이에요.

왜 같은 테마가 함께 무너지나

건설주 4개를 담고 있을 때 건설 섹터에 악재가 터지면 4개가 동시에 내려갑니다. 개별 종목은 분산됐지만 테마 리스크는 전혀 분산되지 않은 거예요. 금리 인상, 부동산 규제, 원자재 가격 급등 같은 이벤트는 건설주 전체를 한꺼번에 흔듭니다. 5월 4일이 바로 그런 날이었을 거예요.

봇을 탓할 수 없었다. 그 규칙을 만든 건 나였으니까.

해결책 설계 — 어떤 방식으로 제한할 것인가

설계

코드를 짜기 전에 방식을 먼저 골랐어요. 테마 집중을 막는 방법은 크게 세 가지가 있습니다.

방식 A — 테마당 최대 N개 제한
"건설주는 최대 2개까지만." 가장 단순하고 직관적입니다. 코드도 짧아요. CONFIG 값 하나만 바꾸면 엄격함을 조절할 수 있어요.
방식 B — 테마 비중 상한
"전체 포지션의 30%를 넘는 테마는 진입 금지." 유연하지만 포지션 수에 따라 기준이 달라져서 관리가 복잡합니다.
방식 C — 상관계수 기반
테마 무관하게 수익률 상관관계가 높으면 진입 금지. 가장 정교하지만 데이터 수집이 필요하고 구현 난이도가 높습니다.
✓ 방식 A 선택 — 테마당 최대 2개, CONFIG로 관리
코드가 단순할수록 버그가 적고 의도가 명확합니다. 숫자 하나(sector_max_per_theme)만 바꾸면 엄격함을 조절할 수 있어요. 처음 도입하는 제한이라면 단순한 것부터 시작하는 게 맞습니다.

코드 구현 — 테마 맵 만들고 진입 전에 체크

코드 공개

구현은 세 단계입니다. 종목-테마 매핑 딕셔너리를 만들고, ETF를 별도로 감지하고, 감시 리스트 스캔 중에 테마 한도를 체크하는 순서예요.

먼저 TICKER_THEME 딕셔너리입니다. 코드에서는 "섹터" 대신 "테마(theme)"라고 불러요. 반도체, 2차전지, 바이오, 건설, 조선, 방산, 원전, AI, 인터넷, 자동차, 금융, 엔터 — 총 12개 테마에 80개 이상의 종목을 직접 매핑해뒀습니다.

반도체12개
2차전지9개
바이오8개
건설6개
조선6개
방산7개
원전2개
AI2개
인터넷4개
자동차6개
금융11개
엔터7개
# 코드 스니펫 ① — TICKER_THEME 딕셔너리 (건설 섹션 발췌) TICKER_THEME: Dict[str, str] = { # 건설 (6개) "047040": "건설", # 대우건설 "006360": "건설", # GS건설 "028050": "건설", # 삼성E&A "375500": "건설", # DL이앤씨 "000720": "건설", # 현대건설 "294870": "건설", # HDC현대산업개발 # 반도체 (12개), 2차전지 (9개), 바이오 (8개) ... 총 12개 테마 # 미매핑 종목 → '기타' (테마 제한 미적용, 진입 허용) } CONFIG = { "sector_max_per_theme": 2, # 테마당 최대 보유 종목 수 # ... }

딕셔너리에 없는 종목은 자동으로 "기타"로 분류됩니다. 기타 종목은 테마 제한을 받지 않아요. 5월 4일 손절 직후 새로 진입한 대원전선·세아메카닉스가 바로 이 경우예요. 테마 맵에 등록되지 않은 종목이라 제한 없이 진입됐습니다.

두 번째는 ETF 예외 처리입니다. ETF는 테마와 무관하게 담을 수 있어야 하므로, 종목명에 KODEX, TIGER, RISE 같은 키워드가 포함되면 테마를 "ETF"로 반환하고 제한 체크에서 제외합니다.

# 코드 스니펫 ② — ETF 감지 + 테마 반환 함수 ETF_NAME_KEYWORDS = ( "KODEX", "TIGER", "RISE", "KBSTAR", "HANARO", "ARIRANG", "SOL", "ACE", "ETF", "ETN", "인버스", "레버리지", ) def get_ticker_theme(ticker: str, name: str = "") -> str: """종목코드 → 테마 반환. ETF·기타는 섹터 제한 미적용.""" for kw in ETF_NAME_KEYWORDS: if kw in name.upper(): return "ETF" # ETF는 제한 없음 return TICKER_THEME.get(ticker, "기타") # 미매핑 → '기타'

마지막으로 핵심 체크 로직입니다. 진입 함수 안이 아니라 감시 리스트 스캔 단계에서 미리 걸러냅니다. 신호 생성 자체를 막는 방식이라 더 효율적이에요. 그리고 보유 종목만이 아니라 주문 접수 중(pending)인 종목도 카운트에 포함합니다. "주문했는데 아직 체결 안 됐다"는 이유로 같은 테마를 또 담는 걸 막기 위해서예요.

# 코드 스니펫 ③ — 스캔 중 테마 한도 체크 # 보유 + pending 합산으로 테마 카운트 (더 안전) active_tickers = set(self.positions.keys()) | pending_tickers sector_count: Dict[str, int] = {} for tk in active_tickers: theme = get_ticker_theme(tk, self.name_cache.get(tk, "")) if theme not in ("ETF", "기타"): sector_count[theme] = sector_count.get(theme, 0) + 1 # 각 후보 종목 스캔 시 theme = get_ticker_theme(ticker, name) max_per = CONFIG.get("sector_max_per_theme", 2) # 테마당 최대 2개 if theme not in ("ETF", "기타") and sector_count.get(theme, 0) >= max_per: blocking = [tk for tk in active_tickers if get_ticker_theme(tk) == theme] log.info(f" 🚫 [{ticker}] {theme} 테마 한도({max_per}개) 초과 " f"(보유/pending: {blocking})") continue # 이 종목은 신호 생성 건너뜀
대한광통신(010170)이 AI 테마인 이유

TICKER_THEME를 보면 대한광통신이 "통신장비"가 아닌 "AI" 테마로 분류돼 있어요. 광통신 인프라가 AI 데이터센터 확장의 핵심 수혜주로 부각되던 시기여서 AI 관련주로 묶었습니다. 테마 분류는 정답이 없어요. 시장 흐름과 내 판단에 따라 계속 업데이트해야 합니다.

시뮬레이션 — 테마 제한이 있었다면 5월 4일은 달랐을까

검증

sector_max_per_theme = 2가 처음부터 적용됐다면 어떻게 됐을까요. 4월 8일 삼성E&A와 GS건설은 그대로 진입됩니다. 건설 테마 2개, 딱 한도예요. 4월 9일 대우건설 진입 시도에서 막혔을 거예요. 건설이 이미 2개 → 한도 초과 → 진입 취소. DL이앤씨도 마찬가지입니다.

실제 (테마 제한 없음)

건설 4개 (삼성E&A·GS건설·대우건설·DL이앤씨)
GS건설 13주 → 104주 누적
5/4 손실: -404,781원

개선 후 (테마당 2개 제한)

건설 최대 2개 (삼성E&A·GS건설)
대우건설·DL이앤씨 진입 차단
회피 손실: DL이앤씨 -80,428원

DL이앤씨(-80,428원)는 처음부터 진입되지 않았을 테니 그 손실은 피할 수 있었어요. GS건설(-320,442원)은 진입 자체는 허용됐겠지만, 포트폴리오가 더 분산됐다면 GS건설에 자금이 이렇게 집중되지 않았을 거예요.

물론 트레이드오프도 있습니다. 테마 제한이 있었다면 4월 초 건설 강세 구간에서 대우건설과 DL이앤씨가 오르는 것을 보기만 했을 거예요. 하지만 저는 그 선택이 맞다고 생각해요. 위험을 줄이는 게 수익을 늘리는 것보다 먼저니까요.

분산은 수익을 포기하는 게 아니다. 한 번의 큰 손실이 여러 번의 작은 수익을 날려버리는 것을 막는 것이다.

숫자 하나로 엄격함을 조절할 수 있다

sector_max_per_theme 값을 바꾸는 것만으로 분산 강도를 조절할 수 있어요. 1로 낮추면 테마당 1개만 허용하는 가장 엄격한 분산, 3으로 높이면 지금보다 느슨하게 운용할 수 있습니다. 처음엔 2로 시작해서 운용 결과를 보며 조정하는 걸 추천해요.

전략을 개선한다는 것

마무리

코드 세 블록이었어요. 테마 맵 딕셔너리 하나, ETF 감지 함수 하나, 스캔 중 체크 로직 하나. 23일간 쌓인 문제를 고치는 데 30줄이 채 안 됐습니다.

자동매매의 강점이 여기 있다고 생각해요. 실패를 규칙으로 만들 수 있다는 것. 사람이 직접 매매했다면 "이번엔 건설주 좀 줄여야지"라고 다짐하다가 또 건설이 오르면 슬쩍 담게 됩니다. 의지는 감정 앞에서 약해지거든요. 하지만 코드는 다짐하지 않아도 됩니다. 그냥 막습니다.

이번 편에서 적용한 테마 분산 로직은 지금도 돌아가고 있어요. 완벽한 해결책이라고는 생각하지 않습니다. 같은 테마가 아니어도 동시에 무너지는 경우가 있고, 테마 맵을 계속 업데이트해줘야 하는 번거로움도 있어요. 하지만 아무것도 없는 것보다는 훨씬 낫습니다. 전략은 완성되는 게 아니라 계속 나아지는 것이니까요.

봇을 개선하는 건 코드를 고치는 게 아니다. 내가 놓친 규칙을 찾아내는 것이다.

반응형