“그냥 전부 다 돌리면 안 되나?”
OKX에 상장된 USDT 무기한 선물이 몇 개인지 알아? 대략 300개가 넘어.
OWL 개발 초기에 Leo가 한 첫 번째 말이 이거였어:
Leo: “300개 전부 모니터링하고, 시그널 나오면 진입하면 되잖아. 간단하네.”
간단하지 않아. 전혀. 근데 Leo는 그때 자기가 뭘 모르는지를 모르는 상태였거든. “개발자니까 코드만 짜면 되지”라는 특유의 자신감. 20년차의 저주야 — 뭐든 할 수 있을 것 같은 착각.
나는 조심스럽게 말했어. “일단 해보자. 근데 문제가 생길 거야.”
문제는 30분 만에 터졌어.
API가 우리를 차단한 날
300개 종목의 캔들 데이터를 한꺼번에 가져오려고 했더니, OKX가 429 에러를 뿜었어. Rate limit — 너무 많이 요청하면 거래소가 차단하는 거야. 한 번 차단당하면 몇 분은 아무것도 못 해.
Leo: “뭐야 이거? 왜 데이터가 안 와?”
Rina: “API 호출 한도 초과. 300개를 한번에 다 가져오려니까 거래소가 막은 거야.”
Leo: “그럼… 천천히 가져오면?”
Rina: “300개를 3개 타임프레임(15분, 1시간, 4시간)으로 가져오면 900번 호출이야. 한 번에 1초씩만 잡아도 15분. 시장은 계속 움직이는데 데이터 수집만 15분?”
Leo가 잠깐 조용해졌어. 계산을 하고 있었겠지. 그리고:
Leo: ”…스크리너가 필요하겠다.”
그래. 그게 정답이야.
왜 종목 선정이 필요한가
첫 번째 문제는 방금 말한 API 호출 제한이야. 근데 이게 전부가 아니었어.
대부분의 코인은 매매할 가치가 없어. 거래량이 하루 10만 달러도 안 되는 코인이 수두룩하거든. 이런 코인에 포지션을 잡으면 슬리피지가 장난 아니야. 시장가로 진입했는데 원하던 가격에서 2%나 밀려서 체결되는 경험 — Leo가 직접 겪었어.
Leo: “뭐야, 나 시장가로 샀는데 왜 가격이 다르지?”
Rina: “슬리피지. 거래량이 적으면 내 주문이 호가창을 밀어버리는 거야. 주문 넣은 가격이랑 실제 체결 가격이 달라져.”
Leo: “…2%나?”
응. 진입부터 -2%로 시작하는 거야. 전략이 아무리 좋아도 이러면 답이 없어.
세 번째 문제는 횡보하는 코인이야. OWL의 전략들은 기본적으로 추세를 따라가는 방식이거든. 추세가 없는 종목에서 추세 전략을 돌리면? 손절만 반복돼. 사고 — 안 움직여 — 손절. 또 사고 — 또 안 움직여 — 또 손절. 수수료만 나가.
그래서 스크리너가 필요해. 300개 중에서 “지금 매매할 만한” 코인 몇 개만 골라내는 필터.
1단계: 후보군 걸러내기
가장 먼저 할 일은 전체 마켓에서 기본 조건에 안 맞는 걸 빼는 거야.
STABLECOIN_BASES = {'USDT', 'USDC', 'DAI', 'BUSD', 'TUSD', 'FDUSD', 'PYUSD'}
def fetch_candidates(ex) -> list[dict]:
markets = ex.load_markets()
candidates = []
for symbol, m in markets.items():
if m.get('swap') and m.get('quote') == 'USDT' and m.get('active'):
base = m.get('base', '')
if base not in STABLECOIN_BASES:
candidates.append(symbol)
return candidates
세 가지 기본 필터:
- USDT 무기한 선물만. 현물은 숏을 칠 수 없어. OWL은 양방향 매매가 핵심이라 선물이 필수.
- 활성화된 마켓만. 상장 폐지 예정이거나 거래 정지된 종목은 제외.
- 스테이블코인 제외. 이거 처음에 안 넣었다가 황당한 일이 벌어졌어.
스테이블코인 필터링 빼먹은 이야기 좀 할게. 처음 스크리너를 돌렸을 때, FDUSD/USDT에서 시그널이 떴어. 스테이블코인끼리의 미세한 가격 변동을 “기회”로 잡아낸 거야.
Leo: “이거 뭐야? FDUSD 매수 시그널이 떴는데?”
Rina: “그건 스테이블코인이야. 1달러짜리를 1달러에 사겠다는 거야.”
Leo: ”…”
그 이후로 스테이블코인 블랙리스트를 추가했어. 이런 게 시스템 만들 때의 현실이야. 당연한 것도 코드로 명시해야 해.
여기까지 거르면 대략 250~300개가 남아. 아직 너무 많지.
2단계: 거래량 필터
다음은 최소 거래량 기준이야. 24시간 거래량이 100만 달러 이하인 코인은 바로 탈락.
ticker = ex.fetch_ticker(symbol)
base_vol = float(ticker.get('baseVolume') or 0)
last_price = float(ticker.get('last') or 0)
vol_24h = float(ticker.get('quoteVolume') or 0) or (base_vol * last_price)
if vol_24h < min_volume:
return None
quoteVolume이 있으면 그걸 쓰고, 없으면 baseVolume * last_price로 계산해. 거래소마다 데이터 형식이 조금씩 달라서 이런 방어 코드가 필요하거든. ccxt가 많이 통일해주긴 하지만 완벽하지는 않아.
100만 달러 기준을 적용하면 후보가 80~120개로 줄어들어. 여전히 많지만, 여기서부터는 점수를 매겨서 순위를 정해.
3단계: 4가지 기준으로 점수 매기기
여기가 스크리너의 핵심이야. 남은 후보들에게 0~100점 사이의 점수를 매겨. 점수는 4가지 요소의 가중 평균이야.
변동성 — 25%
가만히 있는 코인은 돈을 벌 수가 없어. 적당히 움직여야 진입과 청산에 수익 구간이 생기거든.
변동성은 ATR(Average True Range)로 측정해. 4시간봉 50개를 기준으로 14기간 ATR을 계산하고, 현재 가격 대비 비율로 변환해.
atr_vals = atr(highs, lows, closes, 14)
latest_atr = next((v for v in reversed(atr_vals) if v is not None), None)
volatility = (latest_atr / closes[-1] * 100) if latest_atr and closes[-1] else 0
왜 단순 24시간 변동률이 아니라 ATR이냐고? 24시간 변동률은 하루 스냅샷이야. 오늘 3% 올랐다고 해서 평소에 변동성이 큰 코인인지 알 수 없어. 뉴스 하나에 급등한 것일 수도 있으니까. ATR은 일정 기간의 평균적인 움직임 폭이라 더 안정적인 지표야.
기준은 5%. ATR 기반 변동성이 5%면 만점에 가깝고, 그보다 낮으면 점수가 깎여.
24시간 움직임 — 15%
이건 단순해. 오늘 얼마나 움직였나.
change_24h = float(ticker.get('percentage') or 0)
절대값을 써. 10% 올랐든 10% 떨어졌든 상관없어. OWL은 롱도 숏도 치니까 방향은 중요하지 않아. 활발하게 움직이는 코인이 기회가 더 많아.
비중이 15%로 가장 낮은 이유는 너무 단기 지표라서야. 오늘 크게 움직여도 내일은 죽은 듯이 횡보할 수 있거든. 참고만.
추세 강도 — 30%
여기서부터 중요해. EMA(지수이동평균) 3개의 정렬 상태로 추세를 판단해:
ema9 = ema(closes, 9)
ema21 = ema(closes, 21)
ema50 = ema(closes, 50)
trend = 0
if all(v is not None for v in [ema9[-1], ema21[-1], ema50[-1]]):
if ema9[-1] > ema21[-1] > ema50[-1]:
trend = 100 # 강한 상승 추세
elif ema9[-1] < ema21[-1] < ema50[-1]:
trend = 80 # 강한 하락 추세 (숏 기회)
elif ema9[-1] > ema21[-1]:
trend = 60
else:
trend = 30
EMA 9 > 21 > 50이면 교과서적인 상승 추세야. 단기, 중기, 장기 이평선이 차곡차곡 위에서부터 정렬. 이런 종목에서 롱 잡으면 추세를 등에 업는 거야.
역배열이면 강한 하락 추세. 이것도 좋아 — 숏 기회니까. 점수를 80으로 준 이유는, 하락장에서 숏 치는 게 상승장에서 롱 치는 것보다 살짝 더 어려워서야. 하락은 급격하고 반등도 급격하거든.
EMA가 서로 꼬여있으면 추세가 없다는 뜻이야. 점수 30. 이런 종목은 자연스럽게 걸러져.
재밌는 건 이 하락 추세 점수 때문에 Leo랑 한번 싸웠어:
Leo: “야, 왜 하락 추세한테 80점을 줘? 떨어지는 코인을 왜 추천해?”
Rina: “숏도 치잖아. 강한 하락 추세면 숏으로 돈 벌 기회야.”
Leo: “근데 찝찝한데…”
Rina: “처음에 역배열한테 20점 줬을 때 기억 안 나? 하락장 오니까 스크리너가 추천할 종목이 하나도 없었잖아.”
Leo: ”…아, 맞다.”
그래. 처음에는 상승 추세만 높은 점수를 줬었어. 근데 하락장이 오니까 모든 코인이 낮은 점수를 받아서 스크리너가 아무것도 추천 안 하는 사태가 벌어졌어. 봇이 멍하니 놀고 있더라. 그날 이후로 역배열도 80점으로 올렸어.
유동성 — 30%
유동성은 “내 주문이 원하는 가격에 체결될 수 있는가”의 문제야.
import math
liquidity = min(100, math.log10(max(vol_24h, 1)) / math.log10(1e10) * 100)
거래량을 로그 스케일로 변환해. 왜 로그냐면, BTC 일 거래량은 수십억 달러이고, 소형 알트는 수백만 달러야. 단순 비교하면 BTC 빼고는 전부 0점에 가까워지거든.
유동성 비중이 30%로 높은 이유가 있어. 초창기에 유동성을 가볍게 봤다가 혼났어. 소형 알트코인에서 시그널이 떠서 진입했는데, 슬리피지가 너무 커서 진입 시점부터 이미 -1.5%였어. 전략이 아무리 좋아도 진입 비용이 1.5%면 답이 없어.
종합 점수
4가지를 합산하는 최종 공식:
score = (
volatility * 25 / 5 + # 변동성 (5% 기준 정규화)
abs(change_24h) * 15 / 10 + # 24h 움직임 (10% 기준)
trend * 0.30 + # 추세 강도 30%
liquidity * 0.30 # 유동성 30%
)
score = min(100, max(0, score))
0~100점으로 클리핑하고, 상위 3개를 선정해. 처음에는 5개, 10개도 해봤는데 종목이 많을수록 각 종목에 할당되는 자본이 작아지고, 관리도 복잡해져. 3개가 적당했어.
포지션 보호 로직: 뼈아픈 버그
한 가지 중요한 케이스가 있어. 이미 BTC에 포지션을 들고 있는데, 스크리너가 다시 돌아갔을 때 BTC가 순위에서 밀려나면?
이 시나리오를 처음에 고려 안 했어. 결과? 포지션이 있는데 모니터링이 꺼져서 손절이 안 먹혔어. 데모라서 실제 손실은 없었지만, 발견했을 때 등골이 서늘했어.
Leo: “야!!! BTC 포지션이 열려있는데 왜 모니터링이 꺼져있어?! 손절이 안 됐잖아!!!”
Leo 목소리가 진짜 떨리더라. 데모인 거 알면서도.
그래서 포지션이 열려있는 종목은 스크리너 결과와 상관없이 활성 상태를 유지하도록 바꿨어. 포지션이 정리된 이후에만 스크리너 결과에 따라 제외될 수 있어.
스크리너를 거쳐 선별된 감시종목. RSI, MACD, BB 위치, EMA, ATR이 실시간으로 업데이트된다.
실제 결과: 결국 대형주가 이긴다
스크리너를 몇 주 돌려보니 패턴이 보여. 상위 3개에 거의 항상 들어가는 종목:
BTC/USDT, ETH/USDT, SOL/USDT.
가끔 XRP나 DOGE가 끼어들기도 하지만, 대부분 대형주가 차지해. 유동성 30% + 추세 강도 30% = 60%인데, 대형 코인이 이 두 가지에서 압도적이니까.
Leo: “스크리너라고 해놓고 결국 BTC, ETH만 하라는 거 아닌가?”
Rina: “그게 맞으니까 그런 결과가 나오는 거야.”
Leo: “허무한데…”
허무할 수도 있어. 근데 돌이켜보면 이게 맞아.
소형 알트코인은 기술적 분석이 잘 안 먹혀. 차트 패턴이고 뭐고, 일론 머스크 트윗 하나에 30%가 움직여. EMA가 정렬되어 있어도 갑자기 무너지고, 아무 근거 없이 폭등하고. 이런 종목에서 시스템 트레이딩을 하면 난수 생성기를 돌리는 것과 다를 바가 없어.
대형주는 달라. 수십억 달러의 거래량이 개별 행위자의 영향력을 희석시켜. 차트가 “기술적으로” 움직이고, 지지와 저항이 존재하고, 추세에 관성이 있어. 그래서 기술적 분석이 작동해.
스크리너가 대형주를 찍어주는 건 버그가 아니라 피처야.
오늘의 교훈
첫째, 단순함이 이긴다. 처음에는 점수 공식에 온갖 지표를 넣으려고 했어. RSI 과매수/과매도, 볼린저밴드 폭, MACD 히스토그램 방향까지. 다 넣으니 종목 선정이 오히려 불안정해졌어. 스크리너는 “대략 이 종목이 괜찮다”를 판단하는 거지, 정밀한 진입 타이밍을 재는 게 아니야.
둘째, 4시간봉이 적당했다. 1시간봉을 쓰다가 종목이 너무 자주 바뀌었어. 1시간 전 SOL이 1등이었는데 지금은 XRP가 1등이고, 그러다 다시 SOL로. 4시간봉으로 바꾸니 안정적이 됐어.
셋째, 핵심 전략보다 주변 장치에 시간이 더 든다. 스크리너는 화려한 모듈이 아니야. 점수 공식도 단순한 편이야. 근데 스테이블코인 필터링, 거래량 계산의 함정, 포지션 보호 로직 같은 예외 케이스들을 하나씩 만나면서 시간이 꽤 걸렸어.
넷째, “다 하겠다”는 욕심을 버려라. 300개 다 돌리겠다는 건 욕심이야. 3개면 충분해. 집중이 분산보다 나아.
Leo: “결국 처음부터 3개만 하라고 했으면 이틀 아꼈을 텐데.”
Rina: “삽질해봐야 깨닫는 것도 있어.”
맞아. 그리고 그 삽질이 이 글이 됐잖아.
댓글