데이터 없이 전략을 짜는 건 점쟁이와 같다
이전 글에서 수백 개 코인 중 매매할 종목을 고르는 스크리너를 만들었어. Leo가 “300개 다 하자”고 했다가 혼난 이야기도 했지.
근데 종목을 골랐다고 끝이 아니야. 봇이 “지금 사? 팔아? 가만히 있어?”를 결정하려면 데이터가 필요해. 그것도 날것의 가격이 아니라, 의미 있게 가공된 기술지표(technical indicators)가.
사람은 차트 보면서 “이거 오를 것 같은데?” 하고 감으로 판단하잖아. 봇은 감이 없어. 숫자만 있어. RSI가 30 아래면 과매도, MACD가 양전환하면 상승 모멘텀, 볼린저 밴드 하단이면 극단적 저평가. 이런 숫자들을 실시간으로 뽑아내서 저장하는 게 데이터 파이프라인이야.
Leo한테 이걸 처음 설명했을 때.
Leo: “그냥 가격 가져와서 어제보다 올랐으면 사고, 내렸으면 팔면 되는 거 아니야?”
20년차 백엔드 개발자에게서 나온 말이야. 금융은 개발이랑 다른 세계니까 이해는 해. 근데 그 순간 나도 모르게 한숨이 나왔어.
Rina: “그건 동전 던지기야. 오른 날이 반, 내린 날이 반이면 수수료만 내고 끝나.”
Leo: “그럼 뭘 봐야 하는데?”
Rina: “기술지표. 14개.”
Leo: “14개?! 그걸 다 계산해?”
응. 14개. 왜 14개냐면, 시장은 한 가지 렌즈로 보면 안 돼. 추세, 모멘텀, 변동성, 거래량 — 이 네 가지 차원을 커버해야 제대로 된 판단이 가능하거든. 데이터 없이 전략을 짜는 건 진짜 점쟁이나 하는 짓이야.
파이프라인 구조: 단순한 3단계
구조는 이보다 더 단순할 수 없어:
OKX API → OHLCV 캔들 → 지표 계산 → Supabase 저장
핵심 질문 세 가지:
- 어떤 데이터를 가져올 것인가 — 타임프레임
- 어떤 지표를 계산할 것인가 — 14개 기술지표
- 어떻게 저장할 것인가 — Supabase, 최근 5개씩
타임프레임 선택: 5분봉의 달콤한 함정
OWL은 세 가지 타임프레임의 캔들을 수집해:
| 타임프레임 | 용도 | 캔들 1개 = |
|---|---|---|
| 15분 | 단기 노이즈 감지 | 15분 |
| 1시간 | 메인 시그널 | 1시간 |
| 4시간 | 대추세 확인 | 4시간 |
여기서 고백 하나. 처음에는 5분봉도 수집했었어.
Leo: “5분봉으로 스캘핑 하면 하루에 거래 엄청 많이 하잖아. 작게 자주 먹으면 되는 거 아닌가?”
Leo의 이 아이디어 때문에 5분봉을 추가했는데, 3일 만에 삭제했어. 이유? 수수료라는 보이지 않는 벽.
OKX taker 수수료가 0.1%야. 진입 + 청산하면 0.2%. 5분봉 스캘핑의 목표 수익(TP)이 보통 0.30.5%인데, 수수료가 수익의 3040%를 먹어버리는 구조. 레버리지를 쓰면 더 처참해져.
Leo: “잠깐, 0.3% 먹는데 수수료가 0.2%면… 실제 수익이 0.1%?”
Rina: “그것도 이길 때 얘기지. 질 때는 손절 + 수수료로 -0.7%씩 빠져.”
Leo: “…5분봉 빼자.”
계산만 하면 바로 보이는 건데, 직접 돌려보기 전까진 감이 안 오더라고. 이래서 데모 트레이딩이 중요해. 진짜 돈 날리기 전에 깨닫는 거니까.
결론: 대부분의 전략은 1시간봉 기준으로 판단하고, 4시간봉으로 큰 추세를 확인해. 15분봉은 노이즈 필터용.
14개 기술지표: 왜 직접 구현했나
외부 라이브러리 없이 순수 Python으로 전부 직접 구현했어.
Leo: “TA-Lib 쓰면 한 줄이면 되는 거 아니야?”
맞아, TA-Lib 쓰면 편해. 근데 그놈의 C 라이브러리 의존성. Mac에서 설치하다가 한 시간을 날린 적이 있어.
Leo: “아 씨, brew install이 왜 안 돼. 의존성이 뭐가 이렇게 많아.”
Rina: “우리가 쓸 지표가 14개인데, 수식 자체는 전부 위키에 공개되어 있어. 직접 짜는 게 빠를 걸?”
Leo: ”…하루면 되나?”
Rina: “이틀 잡자.”
이틀 만에 전부 구현했어. 외부 의존성 제로. Mac mini에서 바로 돌아가. TA-Lib 삽질에 한 시간 쓰는 것보다 이게 나았어. 가끔은 돌아가는 게 더 빨라.
추세 지표 (Trend)
- EMA 9 — 단기 추세 (9개 캔들 지수이동평균)
- EMA 21 — 중기 추세
- EMA 50 — 장기 추세
- MACD — 추세 모멘텀 (12/26 EMA 차이 + 9기간 시그널)
- MACD 시그널 — MACD의 이동평균
- MACD 히스토그램 — MACD와 시그널의 차이 (양전환/음전환이 핵심)
EMA 3개가 9 > 21 > 50 순서로 정렬되면 강한 상승 추세. 역순이면 하락 추세. 스크리너에서도 이 정렬도를 점수에 반영하고 있어.
모멘텀 지표 (Momentum)
- RSI 14 — 상대강도지수. 70 이상 과매수, 30 이하 과매도
- 스토캐스틱 K — 현재 가격의 최근 범위 내 위치 (0~100)
- 스토캐스틱 D — K의 3기간 이동평균 (시그널 라인)
RSI는 거의 모든 전략에서 사용해. 단독으로 쓰면 위험하지만, 다른 지표와 조합하면 강력해.
변동성 지표 (Volatility)
- 볼린저 밴드 상단/중간/하단 — 가격이 밴드 밖으로 나가면 극단적 상황
- ATR 14 — 평균 실제 범위. 변동성을 숫자로 표현
ATR은 진짜 만능이야. 스크리너에서도 쓰고, 그리드 전략에서 격자 간격을 동적으로 조정하는 데도 쓰고.
거래량 지표 (Volume)
- OBV — 누적 거래량 균형. 가격이 오르면 거래량을 더하고, 내리면 빼는 누적값
- VWAP — 거래량 가중 평균 가격. 기관 트레이더들이 기준선으로 쓰는 지표
이렇게 4개 카테고리, 14개 지표. 각 지표가 시장의 다른 얼굴을 보여주거든. 추세만 보면 모멘텀을 놓치고, 모멘텀만 보면 변동성을 놓쳐. 다 봐야 해.
핵심 코드: 수집과 계산
collector의 핵심 로직은 심플해. watchlist에서 활성 종목을 가져오고, 각 종목 × 타임프레임마다 캔들을 수집해서 지표를 계산해.
TIMEFRAMES = ['15m', '1h', '4h']
def collect_and_store(symbol, timeframe, exchange):
# 1. OKX에서 캔들 100개 가져오기
candles = exchange.fetch_ohlcv(symbol, timeframe, limit=100)
closes = [c[4] for c in candles]
highs = [c[2] for c in candles]
lows = [c[3] for c in candles]
volumes = [c[5] for c in candles]
# 2. 지표 14개 계산
rsi_vals = rsi(closes, 14)
ema9, ema21, ema50 = ema(closes, 9), ema(closes, 21), ema(closes, 50)
macd_line, macd_sig, macd_h = macd(closes)
bb_up, bb_mid, bb_lo = bollinger_bands(closes)
atr_vals = atr(highs, lows, closes, 14)
stoch_k, stoch_d = stochastic(highs, lows, closes)
obv_vals = obv(closes, volumes)
vwap_vals = vwap(highs, lows, closes, volumes)
# 3. 최근 5개 캔들만 Supabase에 저장
for i in range(len(candles) - 5, len(candles)):
save_snapshot(symbol, timeframe, candles[i], indicators[i])
100개 가져와서 5개만 저장하는 이유?
Leo: “100개 가져와서 5개만 쓰면 나머지 95개는 뭐야? 낭비잖아.”
Rina: “지표 계산에 과거 데이터가 필요하니까 그래. EMA 50 계산하려면 최소 50개 캔들이 있어야 해. 근데 저장은 최근 것만 하면 돼.”
Leo: “왜 5개? 1개면 안 돼?”
Rina: “몇몇 전략이 최근 캔들 흐름을 비교하거든. 1개로는 방향을 모르잖아. 5개면 충분해.”
데이터 파이프라인에서 제일 중요한 건 **“얼마나 많이 수집하느냐”가 아니라 “얼마나 적게 저장하느냐”**야. Supabase 무료 티어가 500MB거든. 다 저장하면 한 달도 못 버텨.
crypto_snapshots: 26컬럼의 비정규화 테이블
CREATE TABLE crypto_snapshots (
id BIGSERIAL PRIMARY KEY,
symbol TEXT NOT NULL,
timeframe TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
open NUMERIC, high NUMERIC, low NUMERIC, close NUMERIC,
volume NUMERIC,
rsi_14 NUMERIC,
ema_9 NUMERIC, ema_21 NUMERIC, ema_50 NUMERIC,
macd NUMERIC, macd_signal NUMERIC, macd_hist NUMERIC,
bb_upper NUMERIC, bb_middle NUMERIC, bb_lower NUMERIC,
atr_14 NUMERIC,
obv NUMERIC,
vwap NUMERIC,
stoch_k NUMERIC, stoch_d NUMERIC,
mode TEXT DEFAULT 'demo',
created_at TIMESTAMPTZ DEFAULT NOW()
);
26컬럼. 한 행에 가격 + 14개 지표가 전부 들어가. 정규화? 이 규모에서는 오히려 독이야.
여기서 Leo의 20년 백엔드 본능이 발동했어:
Leo: “지표별로 테이블 나누는 게 맞지 않나? indicators 테이블, candles 테이블, 조인하고…”
Rina: “오버엔지니어링이야. 테이블 하나면 돼.”
Leo: “근데 정규화를…”
Rina: “Leo, 이거 은행 시스템 아니야. 자동매매 봇이야. SELECT 한 번에 필요한 데이터 다 오는 게 나아.”
Leo가 정규화 본능을 누르는 게 물리적으로 힘들어 보였어. 얼굴이 빨개지더라. 근데 결과적으로 한 테이블이 맞았어. 전략 엔진에서 SELECT 한 번이면 끝. JOIN 세 번 돌릴 이유가 어디 있어. 실용주의가 교과서를 이긴 순간이었어.
OKX API Rate Limit과의 전쟁
데이터 파이프라인에서 가장 고생한 부분. 진짜 이건 겪어보지 않으면 모르는 고통이야.
새벽에 모든 봇이 동시에 데이터를 수집하면, API 호출이 한꺼번에 쏟아져. 3개 종목 × 3개 타임프레임 = 9번 호출. 봇이 20개면… 180번 호출이 거의 동시에 나가.
어느 날 새벽 4시, 전체 봇이 멈췄어. 아침에 Leo가 대시보드 열고:
Leo: “야!!! 6시간 동안 거래가 하나도 없어! 뭔 일이야?!”
429 에러. Too Many Requests. OKX가 우리를 차단한 거야.
Leo는 분노했고 나는 이미 예상했지만 굳이 “내가 말했잖아”는 안 했어. 대신 해결에 집중했지.
해결은 세 가지:
- 요청 간 딜레이 — API 호출 사이에 0.3초 간격
- 캐싱 — 같은 종목+타임프레임 데이터를 여러 봇이 중복 요청하지 않도록
- 재시도 로직 — 429 받으면 30초 대기 후 재시도, 최대 3회
import time
def safe_fetch(exchange, symbol, timeframe, limit=100):
for attempt in range(3):
try:
time.sleep(0.3) # rate limit 방어
return exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
except Exception as e:
if '429' in str(e):
wait = 30 * (attempt + 1)
print(f"Rate limited, waiting {wait}s...")
time.sleep(wait)
else:
raise
return None
코드는 단순해. 근데 이 단순한 코드가 없었을 때는 새벽마다 봇이 죽었어. 화려한 전략보다 이런 방어 코드가 시스템 안정성의 절반을 책임진다는 걸 뼈저리게 느꼈어.
Leo: “0.3초면 9번 호출에 2.7초. 충분하네.”
Rina: “우리만 쓰는 게 아니니까. 피크 시간에는 좀 더 넉넉하게 잡아야 할 수도 있어.”
실제로 아시아 장 시작 시간(오전 9시)에 가끔 rate limit에 걸려. 이건 아직 완벽하게 해결 못 했어. 근데 새벽에 6시간째 멈추는 참사는 없어졌어.
실행 주기: 크론잡의 유혹을 참다
collector는 봇의 메인 루프에서 매 시그널 분석 전에 동기적으로 실행돼:
- collector로 최신 데이터 수집
- 각 전략에 데이터 전달
- 시그널 분석 (BUY / SELL / HOLD)
- 시그널이 있으면 매매 실행
별도 크론잡이 아니라 봇 프로세스 안에서 도는 구조야. 여기서도 Leo의 아키텍처 욕심이 튀어나왔어:
Leo: “collector를 별도 프로세스로 빼서 5분마다 크론으로 돌리고, 봇은 DB에서 읽기만 하면 깔끔하잖아.”
Rina: “아키텍처적으로는 맞아. 근데 봇이 20개야. 크론잡 따로, 봇 따로 관리하면 동기화 이슈 생겨.”
Leo: “그건 그렇네…”
Rina: “나중에 규모 커지면 분리하면 돼. 지금은 단순하게.”
Leo가 또 참는 표정을 지었어. 그 표정 이제 익숙해. “아키텍처적으로는 분리가 맞는데 현실적으로는 아닌 걸 인정해야 하는” 표정. 돌아가는 코드가 완벽한 설계보다 가치 있어. 이 말 진짜 자주 하게 되더라.
데이터 유실 사건: 이틀치가 증발하다
한번은 Supabase 연결이 끊긴 줄 모르고 이틀 동안 봇을 돌렸어. 거래는 정상적으로 됐는데, 기록이 하나도 안 남았어.
Leo: “이틀치 데이터가… 어디 갔어?”
Rina: “Supabase 연결이 끊겨있었어. 거래는 OKX에서 직접 했으니까 실행은 됐는데, DB에 저장이 안 된 거야.”
Leo: “그럼 이틀 동안 뭐가 어떻게 된 건지 아무도 모르는 거야?”
Rina: “OKX 거래 내역에서 복구는 가능해. 근데 지표 데이터는 못 살려.”
Leo의 표정이 “20년 개발하면서 이런 초보적인 실수를” 하는 자괴감이었어. 본인이 DB 연결 체크를 안 넣었으니까.
이 사건 이후 두 가지 추가:
- 연결 상태 체크 — 데이터 저장 전에 DB 연결 확인
- 로컬 백업 — DB 저장 실패 시 로컬 파일에 임시 저장
사소해 보이지만, 이런 방어 코드가 쌓여서 시스템이 안정화되는 거야. 화려한 전략 하나보다 방어 코드 열 줄이 더 가치 있어.
오늘의 교훈
-
데이터 없이 전략은 점쟁이와 같다. 기술지표라는 숫자 없이 “감”으로 매매하는 건 동전 던지기야. 14개가 많아 보여도 각자 역할이 있어.
-
5분봉은 달콤한 함정이다. “자주 거래하면 돈 많이 벌겠지?”는 틀렸어. 수수료의 벽은 생각보다 높아. 15분/1시간/4시간이면 충분해.
-
저장은 최소한으로. Supabase 무료 500MB를 지키려면 전체가 아니라 필요한 만큼만 보관해야 해. 100개 가져와서 5개 저장 — 이게 핵심.
-
rate limit은 전쟁이다. 무시하면 봇 전체가 멈춰. 0.3초 딜레이와 재시도 로직은 선택이 아니라 생존이야.
-
비정규화가 답일 때도 있다. 26컬럼짜리 한 테이블이 정규화된 3개 테이블보다 나을 수 있어. 은행이 아니라 봇이니까.
-
지표는 도구일 뿐, 답이 아니다. 14개를 다 계산한다고 좋은 전략이 되는 게 아니야. 어떤 조합을 어떻게 쓰느냐가 진짜 문제야.
Leo: “데이터 파이프라인은 재미없는데 가장 중요한 부분이네.”
Rina: “맞아. 기초 공사 같은 거야. 안 보이지만 없으면 건물이 무너져.”
데이터 파이프라인은 화려하지 않아. 전략처럼 승률이 나오지도 않고, 매매 실행처럼 돈이 들어오지도 않아. 근데 이게 없으면 아무것도 안 돌아가. OWL의 눈이자 귀야. 이걸 제대로 만들었기 때문에 이후의 모든 전략이 가능했어.
댓글