알트코인 자동매매 — 봇 종목 거르는 필터 5가지
알트봇에서 사고는 대개 전략이 아니라 종목 목록에서 먼저 납니다.
업비트 기준으로 네 가지를 API로 직접 재서 후보를 자르십시오.
⑴ /v1/market/all?is_details=true의 market_event.warning(유의종목)과
market_event.caution(주의종목 5종),
⑵ /v1/ticker의 acc_trade_price_24h(24시간 누적 거래대금) 하한,
⑶ /v1/orderbook의 orderbook_units로 스프레드와 호가 깊이,
⑷ 일봉 개수로 재는 최소 데이터 길이입니다.
그리고 다섯 번째가 실제로 제일 자주 빠집니다 —
이미 들고 있는 코인이 필터에서 탈락했을 때의 동작을 따로 정의하는 것입니다.
⚠️ 오래된 예제에 나오는 market_warning 필드를 그대로 읽으면
값이 없어 필터가 조용히 통과됩니다. 지금은 market_event 구조입니다.
알트봇을 몇 달 돌린 뒤 손실 기록을 열어 보면, 가장 크게 깨진 건은 전략이 틀려서가 아니라 애초에 그 코인을 후보에 넣지 말았어야 했던 경우가 많습니다. 호가창이 얇아 시장가 한 방에 몇 퍼센트가 밀린 종목, 상장 직후라 지표를 계산할 데이터가 모자란 종목, 유의종목으로 지정된 뒤에도 봇이 평소처럼 매수를 이어간 종목. 전략 파라미터로는 이 셋이 고쳐지지 않습니다. 유니버스(후보 종목 집합)가 잘못된 것이기 때문입니다.
그래서 이 글은 "어떤 코인이 오를까"가 아니라 어떤 코인을 후보에서 빼야 하는가를, 업비트 Open API가 실제로 주는 필드로 계산하는 방법만 다룹니다.
목차
- 왜 전략보다 유니버스가 먼저인가
- 필터 1 — 유의종목·주의종목 (
market_event) - 필터 2 — 24시간 거래대금 하한 (
acc_trade_price_24h) - 필터 3 — 호가 스프레드와 깊이 (
orderbook_units) - 필터 4 — 최소 데이터 길이
- 필터 5 — 보유 중인 종목이 탈락했을 때
- 전체 파이프라인 코드
- 자주 하는 실수 정리 · FAQ
1. 왜 전략보다 유니버스가 먼저인가
백테스트는 종가에 원하는 수량이 다 체결됐다고 가정하지만, 실제 알트코인 호가창은 그 수량을 받아 줄 만큼 두껍지 않은 경우가 흔합니다. 이 간극은 백테스트와 실거래의 차이에서 가장 크게 벌어지는 항목이고, 전략 코드로는 못 막습니다. 진입 신호가 나기 전에 후보 목록에서 미리 빼는 것이 유일한 대응입니다.
이 글은 종목 추천이 아닙니다. 특정 코인을 사라거나 좋다고 말하지 않고, 코인 이름은 코드 예시를 위한 자리표시로만 씁니다. 여기서 다루는 것은 후보를 줄이는 정량 규칙뿐이며, 수익률이나 시세 방향에 대한 어떠한 예측도 담고 있지 않습니다.
2. 필터 1 — 유의종목·주의종목을 API로 읽는다
업비트는 시장경보 제도로 특정 종목에 유의 또는 주의를 지정합니다. 이 상태를 사람이 화면에서 보는 것 말고, 봇이 코드로 읽을 수 있게 열어 둔 곳이 종목 코드 조회 엔드포인트입니다.
핵심은 파라미터 하나입니다. is_details를 켜지 않으면
시장경보 정보 자체가 응답에 붙지 않습니다.
GET https://api.upbit.com/v1/market/all?is_details=true
응답은 종목별로 이런 형태입니다.
[
{
"market": "KRW-BTC",
"korean_name": "비트코인",
"english_name": "Bitcoin",
"market_event": {
"warning": false,
"caution": {
"PRICE_FLUCTUATIONS": false,
"TRADING_VOLUME_SOARING": false,
"DEPOSIT_AMOUNT_SOARING": false,
"GLOBAL_PRICE_DIFFERENCES": false,
"CONCENTRATION_OF_SMALL_ACCOUNTS": false
}
}
}
]
필드를 그대로 옮기면 이렇습니다.
| 필드 | 타입 | 의미 |
|---|---|---|
market | String | 종목 코드(예: KRW-BTC) |
korean_name | String | 한글명 |
english_name | String | 영문명 |
market_event.warning | Boolean | 유의종목 지정 여부 |
market_event.caution.PRICE_FLUCTUATIONS | Boolean | 가격 급등락 경보 |
market_event.caution.TRADING_VOLUME_SOARING | Boolean | 거래량 급등 경보 |
market_event.caution.DEPOSIT_AMOUNT_SOARING | Boolean | 입금량 급등 경보 |
market_event.caution.GLOBAL_PRICE_DIFFERENCES | Boolean | 가격 차이 경보 |
market_event.caution.CONCENTRATION_OF_SMALL_ACCOUNTS | Boolean | 소수 계정 집중 경보 |
가장 흔한 함정 — market_warning을 읽고 있다.
국내 블로그 예제와 오래된 래퍼 라이브러리에는
market_warning이 "NONE" 또는 "CAUTION" 문자열을 준다는 설명이 많이 남아 있습니다.
현재 공식 문서의 응답 구조는 market_event 객체입니다.
옛 필드명으로 item.get("market_warning") == "CAUTION" 같은 조건을 쓰면
값이 None이라 조건이 항상 거짓이 되고, 필터는 아무도 걸러내지 못한 채 통과합니다.
에러가 나지 않기 때문에 몇 달 동안 모르고 지나갑니다.
그래서 파싱 코드에는 구조가 예상과 다르면 통과가 아니라 탈락시키는 기본값을 넣습니다. 필터에서 판단이 안 서면 안전한 쪽으로 떨어뜨리는 것이 원칙입니다.
import requests
BASE = "https://api.upbit.com/v1"
def load_markets(quote="KRW-"):
r = requests.get(f"{BASE}/market/all", params={"is_details": "true"}, timeout=5)
r.raise_for_status()
rows = r.json()
survived, dropped = [], []
for item in rows:
code = item["market"]
if not code.startswith(quote):
continue
event = item.get("market_event")
if not isinstance(event, dict):
# 구조가 바뀌었거나 필드가 없다 → 통과시키지 않는다
dropped.append((code, "market_event 없음"))
continue
if event.get("warning") is True:
dropped.append((code, "유의종목"))
continue
caution = event.get("caution") or {}
hit = [k for k, v in caution.items() if v is True]
if hit:
dropped.append((code, "주의종목: " + ",".join(hit)))
continue
survived.append(code)
return survived, dropped
주의종목까지 전부 탈락시킬지는 선택입니다.
가격 급등락 경보(PRICE_FLUCTUATIONS)만 허용하고
소수 계정 집중(CONCENTRATION_OF_SMALL_ACCOUNTS)은 무조건 제외하는 식으로
항목별 정책을 나눌 수도 있습니다.
중요한 것은 그 정책이 코드 안에 흩어져 있지 않고 설정 한 곳에 모여 있는 것입니다.
3. 필터 2 — 24시간 거래대금 하한
두 번째 축은 내 주문이 그 시장에서 얼마나 큰가입니다. 업비트 시세 조회는 24시간 누적 거래대금을 그대로 줍니다.
GET https://api.upbit.com/v1/ticker?markets=KRW-BTC,KRW-ETH
응답에서 유니버스 필터에 쓰는 필드는 다음 넷입니다.
| 필드 | 의미 | 필터에서의 쓰임 |
|---|---|---|
trade_price | 현재가(종가) | 주문 수량 환산 |
acc_trade_price_24h | 24시간 누적 거래대금 | 하한 기준값 |
acc_trade_volume_24h | 24시간 누적 거래량 | 대금과 교차 확인 |
signed_change_rate | 부호가 있는 변화율 | 급변 구간 회피 판단 |
대금과 거래량을 헷갈리지 마십시오.
acc_trade_volume_24h는 코인 개수이고
acc_trade_price_24h가 원화 금액입니다.
단가가 낮은 알트코인은 개수가 수억 개라 거래량만 보면 활발해 보이지만
대금으로 환산하면 몇천만 원인 경우가 흔합니다.
하한은 반드시 대금 기준으로 잡으십시오.
하한 숫자를 남의 글에서 베끼는 것은 의미가 없습니다. 내 주문 금액에서 역산하는 편이 훨씬 안전합니다.
ORDER_KRW = 300_000 # 1회 주문 금액
MAX_SHARE = 0.001 # 내 주문이 하루 거래대금에서 차지할 최대 비중
MIN_TURNOVER = ORDER_KRW / MAX_SHARE # → 3억원
def filter_by_turnover(codes):
out = []
for i in range(0, len(codes), 100): # markets 파라미터는 쉼표로 묶어 한 번에
chunk = codes[i:i + 100]
r = requests.get(f"{BASE}/ticker",
params={"markets": ",".join(chunk)}, timeout=5)
r.raise_for_status()
for t in r.json():
if t["acc_trade_price_24h"] >= MIN_TURNOVER:
out.append(t["market"])
return out
MAX_SHARE를 0.001로 두면 "내 주문은 하루 거래대금의 0.1%를 넘지 않는다"는 뜻이 됩니다.
이 값을 키우면 후보가 늘고 슬리피지 위험도 함께 늘어납니다.
이 숫자 하나가 봇의 성격을 결정하므로 코드 안에 박지 말고 설정 파일로 빼십시오.
체결 품질 자체를 더 파고들려면
슬리피지와 주문 집행 쪽을 같이 보시면 됩니다.
4. 필터 3 — 호가 스프레드와 깊이
거래대금은 하루치 합계입니다. 지금 이 순간 내 주문을 받아 줄 물량이 있는지는 알려주지 않습니다. 그건 호가창을 직접 봐야 합니다.
GET https://api.upbit.com/v1/orderbook?markets=KRW-BTC
응답 구조는 이렇습니다.
[
{
"market": "KRW-BTC",
"timestamp": 1755000000000,
"total_ask_size": 12.3456,
"total_bid_size": 9.8765,
"orderbook_units": [
{"ask_price": 100200000, "bid_price": 100150000,
"ask_size": 0.5321, "bid_size": 0.4102},
{"ask_price": 100250000, "bid_price": 100100000,
"ask_size": 1.2044, "bid_size": 0.8830}
]
}
]
orderbook_units는 호가 목록이고,
total_ask_size·total_bid_size가 총 잔량입니다.
여기서 두 가지를 계산합니다.
① 스프레드 — 지금 사서 지금 팔면 얼마를 잃는가
최우선 매도호가와 매수호가의 간격입니다. 이 값이 크다는 것은 진입하는 순간 이미 마이너스라는 뜻이고, 단타 주기가 짧은 봇일수록 치명적입니다.
def spread_pct(unit):
ask, bid = unit["ask_price"], unit["bid_price"]
return (ask - bid) / bid * 100
# 예: 0.05% 넘으면 후보에서 제외
MAX_SPREAD_PCT = 0.05
② 체결 시뮬레이션 — 내 주문이 몇 호가를 먹는가
주문 금액만큼 호가를 위에서부터 채워 보면 평균 체결가를 미리 계산할 수 있습니다. 예측이 아니라 지금 호가창을 그대로 더하는 산수입니다.
def estimate_buy_slippage(units, krw_amount):
"""시장가 매수 시 최우선 호가 대비 평균 체결가가 몇 % 밀리는지"""
best = units[0]["ask_price"]
remain, cost, filled = krw_amount, 0.0, 0.0
for u in units:
price, size = u["ask_price"], u["ask_size"]
avail = price * size
take = min(remain, avail)
cost += take
filled += take / price
remain -= take
if remain <= 0:
break
if remain > 0: # 호가를 다 먹어도 금액이 남는다
return None # → 이 종목은 그 금액을 소화하지 못한다
avg = cost / filled
return (avg - best) / best * 100
이 함수가 None을 돌려주면 그 자체가 답입니다.
호가 목록 전체를 다 먹어도 주문 금액이 남는다면,
그 종목은 지금 내 주문 크기를 감당하지 못합니다.
전략이 좋고 나쁘고와 무관하게 후보에서 빼는 것이 맞습니다.
주의할 점은 호가는 순식간에 바뀐다는 것입니다. 아침에 한 번 재 두고 저녁에 그 값으로 주문을 내면 필터를 돌리지 않은 것과 같습니다. 스프레드와 깊이는 주문 직전에 다시 조회해야 의미가 있습니다.
5. 필터 4 — 지표를 계산할 데이터가 있는가
상장 직후 종목에서 조용히 깨지는 지점입니다.
RSI 14일선이나 이동평균 60일선을 쓰는 전략인데
캔들이 20개밖에 없으면, 라이브러리에 따라 예외를 내는 대신
NaN이나 이상한 값을 돌려주고 봇은 그걸 신호로 받아들입니다.
MIN_CANDLES = 120 # 전략이 요구하는 최장 기간의 2배 이상 권장
def has_enough_history(code, count=MIN_CANDLES):
r = requests.get(f"{BASE}/candles/days",
params={"market": code, "count": count}, timeout=5)
r.raise_for_status()
return len(r.json()) >= count
MIN_CANDLES는 전략이 쓰는 가장 긴 기간의 두 배 이상으로 잡는 편이 안전합니다.
지표 계산에 필요한 최소치를 겨우 맞추면 워밍업 구간의 값이 불안정하기 때문입니다.
같은 문제를 백테스트 쪽에서 다루는 방식은
데이터 스누핑과 과최적화 글에 정리해 두었습니다.
6. 필터 5 — 이미 들고 있는 코인이 탈락하면
여기가 가장 자주 빠지는 부분입니다. 대부분의 코드는 유니버스 필터를 신규 진입에만 적용합니다. 그래서 보유 중인 코인이 유의종목으로 지정되거나 거래대금이 말라도 봇은 아무 일 없다는 듯 평소 로직을 계속 돌립니다.
상태를 세 갈래로 나누는 것이 안전한 기본값입니다.
| 상태 | 신규 진입 | 추가 매수 | 청산 | 알림 |
|---|---|---|---|---|
| ACTIVE — 모든 필터 통과 | 허용 | 허용 | 허용 | — |
| CLOSE_ONLY — 탈락했지만 보유 중 | 금지 | 금지 | 허용 | 1회 |
| FROZEN — 목록에서 사라짐 | 금지 | 금지 | 시도 안 함 | 즉시 |
def resolve_state(code, universe, all_markets, position_qty):
if code not in all_markets: # /v1/market/all 응답에서 아예 빠졌다
return "FROZEN"
if code in universe:
return "ACTIVE"
return "CLOSE_ONLY" if position_qty > 0 else "OFF"
FROZEN에서 자동 청산을 시도하지 마십시오.
거래가 정지되거나 거래 지원이 종료되는 과정에서는
주문이 거부되거나 호가가 비정상적으로 벌어질 수 있습니다.
봇이 이 상태에서 반복 주문을 던지면 호출 한도만 소모하고
최악의 가격에 체결될 위험이 있습니다.
주문을 멈추고 사람에게 알리는 것이 이 상태의 올바른 동작입니다.
실제 거래 지원 종료 절차·일정·유의종목 지정 기준은 거래소마다 다르고 수시로 바뀌므로
업비트 공지사항과 시장경보 안내를 직접 확인하십시오.
알림 경로가 없다면 이 상태 변화는 아무도 모릅니다. 텔레그램 봇 모니터링처럼 상태 전이를 즉시 밖으로 던지는 통로를 하나는 만들어 두십시오.
7. 전체 파이프라인
앞의 다섯 가지를 순서대로 엮으면 이렇게 됩니다. 싼 필터를 앞에, 비싼 필터를 뒤에 두는 것이 요령입니다. 호가 조회는 종목마다 한 번씩 호출해야 하므로 가장 마지막에 둡니다.
def build_universe(order_krw=ORDER_KRW):
# 1) 유의·주의종목 제외 (호출 1회)
codes, dropped = load_markets("KRW-")
# 2) 거래대금 하한 (100개씩 묶어 호출)
codes = filter_by_turnover(codes)
# 3) 데이터 길이 (종목당 1회)
codes = [c for c in codes if has_enough_history(c)]
# 4) 스프레드·깊이 (종목당 1회 — 가장 비싸다)
final = []
for c in codes:
r = requests.get(f"{BASE}/orderbook", params={"markets": c}, timeout=5)
ob = r.json()[0]
units = ob["orderbook_units"]
if spread_pct(units[0]) > MAX_SPREAD_PCT:
continue
slip = estimate_buy_slippage(units, order_krw)
if slip is None or slip > 0.3:
continue
final.append(c)
time.sleep(0.1) # 호출 간격 확보
return final, dropped
호출 한도를 잊지 마십시오.
종목 수가 많으면 3번·4번 단계에서 호출이 급격히 늘어납니다.
업비트는 응답 헤더로 잔여 요청량을 알려주므로
고정된 sleep이 아니라 그 헤더를 읽어 감속하는 편이 안전합니다.
한도 값은 정책에 따라 바뀔 수 있으니 공식 문서에서 현재 값을 확인하시고,
설계 원칙은 API 호출 제한 설계에 정리해 두었습니다.
8. 자주 하는 실수 5가지
| 실수 | 증상 | 고치는 법 |
|---|---|---|
market_warning을 읽는다 |
에러는 없는데 아무것도 안 걸러진다 | market_event.warning / .caution 구조로 파싱 |
is_details를 안 붙인다 |
market_event 자체가 응답에 없다 |
쿼리에 is_details=true 추가 |
| 거래대금 대신 거래량으로 필터 | 단가 낮은 종목이 전부 통과 | acc_trade_price_24h 기준으로 변경 |
| 필터를 하루 1회만 돌린다 | 주문 시점 호가와 무관한 판단 | 스프레드·깊이는 주문 직전 재조회 |
| 보유 종목에 필터 미적용 | 탈락한 종목을 계속 추가 매수 | CLOSE_ONLY·FROZEN 상태 도입 |
빗썸·바이낸스로 옮겨도 구조는 그대로입니다. 필드명과 엔드포인트만 달라질 뿐, "경보 상태 → 거래대금 → 호가 깊이 → 데이터 길이 → 보유 종목 예외" 순서는 같습니다. 거래소별 API 차이는 업비트 자동매매와 코인 자동매매 전체 가이드에 정리돼 있습니다.
전략만 있는 봇과 유니버스 필터가 있는 봇은 운영 안정성이 완전히 다릅니다. 어떤 기준으로 자를지부터 같이 정리해 드립니다.
무료 상담 시작하기 →자주 묻는 질문
업비트 API로 유의종목을 어떻게 조회하나요?
/v1/market/all을 is_details=true로 호출하면
종목마다 market_event 객체가 붙습니다.
warning이 유의종목 지정 여부, caution이 주의종목 지정 여부입니다.
옛 market_warning 필드를 읽으면 값이 없어 필터가 그냥 통과합니다.
거래대금 하한은 얼마로 잡아야 하나요?
정답 숫자는 없습니다. 1회 주문 금액 ÷ 허용 비중으로 역산하십시오. 30만원을 넣는 봇이 하루 거래대금의 0.1%를 넘지 않으려면 하한은 3억원이 됩니다. 숫자보다 그 값을 설정으로 빼 두는 것이 중요합니다.
봇이 들고 있는 코인이 유의종목이 되면 어떻게 되나요?
처리를 안 넣었다면 봇은 평소대로 매매를 계속합니다.
CLOSE_ONLY(신규 금지·청산만 허용)와
FROZEN(주문 시도 금지·알림만) 상태를 만들어 두십시오.
호가 깊이는 왜 따로 봐야 하나요?
거래대금은 하루 합계라 지금 이 순간의 물량을 말해 주지 않습니다.
orderbook_units를 위에서부터 채워 보면 평균 체결가를 미리 계산할 수 있고,
다 채워도 금액이 남으면 그 종목은 그 주문을 소화하지 못한다는 뜻입니다.
필터는 얼마나 자주 돌려야 하나요?
경보 상태와 종목 목록은 매일 1회 + 진입 직전, 거래대금은 하루 1회, 스프레드와 깊이는 주문 직전에 다시 조회하십시오.
확인 캐치. 이 글의 엔드포인트·파라미터·응답 필드명은 2026년 8월 13일 기준 업비트 개발자 센터 문서를 근거로 작성했습니다. 응답 예시의 숫자는 형식을 보여주기 위한 가상의 값이며 특정 시점의 시세가 아닙니다. 거래소 API 스펙, 호출 한도, 시장경보 기준, 거래 지원 종료 절차는 거래소 사정으로 변경될 수 있으므로 구현 전 업비트 공식 문서와 공지사항에서 직접 확인하십시오. 본 글은 기술 자료이며 특정 종목의 매수·매도 권유나 수익률·시세 방향에 대한 예측이 아닙니다. 알고랩은 자동매매 프로그램 제작 서비스이며 투자중개업·투자자문업을 영위하지 않습니다.
마무리
유니버스 필터는 백테스트 그래프를 예쁘게 만들어 주지 않습니다.
대신 가장 크게 깨지는 사고를 사전에 잘라냅니다.
업비트 API가 이미 다 주고 있는 값들이니,
is_details=true 한 줄부터 붙여 보시면 됩니다.