VWAP 스캘핑 2편 — 수수료를 줄이니 체결이 나를 골랐다
수수료의 압박을 이겨보려고 taker를 버리고 포스트온리로 갔습니다. 비용은 줄었는데 대신 체결이 선택되기 시작했습니다. 그 과정을 코드까지 열어 정리했습니다.
1편에서 VWAP 스캘핑 봇을 돌려보고 남은 결론은 하나였습니다. 신호는 그럭저럭 맞는데 수수료를 빼면 남는 게 없다. 그래서 2편에서는 진입 로직을 더 정교하게 다듬는 대신, 비용 구조 자체를 건드려봤습니다. taker를 버리고 메이커로만 들어가는 방식이요. 결과는 영상에 그대로 나와 있고, 이 글은 무엇을 어떻게 바꿨고 왜 그 방향이 생각만큼 안 통했는지를 코드까지 열어 정리한 겁니다.
스캘핑의 상대는 시장이 아니라 수수료다
스캘핑을 하겠다고 마음먹는 순간 상대가 바뀝니다. 며칠 들고 가는 전략은 한 번 이길 때 몇 퍼센트를 먹으니 왕복 0.1%가 반올림 오차 취급을 받습니다. 그런데 한 번에 0.1~0.2%를 노리는 매매에서는 그 0.1%가 기대수익과 같은 자릿수예요. 바이낸스 USDT-M 무기한선물 기본 등급 기준으로 taker가 0.05%, maker가 0.02%입니다. taker로 들어가서 taker로 나오면 왕복 0.1%. 가격이 내가 맞힌 방향으로 0.1%를 움직여야 겨우 본전이고, 거기서부터가 수익입니다. VIP 등급이나 수수료 할인이 붙으면 조금 내려가지만 구조는 그대로고, 등급은 대개 거래량으로 사는 거라 스캘핑을 더 많이 해야 얻어집니다.
- 거래 수수료 — 유일하게 확정적이고, 유일하게 내가 미리 계산할 수 있는 비용입니다. 나머지는 전부 추정치예요.
- 스프레드 — 시장가로 사면 최우선 매도호가에, 시장가로 팔면 최우선 매수호가에 체결됩니다. BTCUSDT처럼 호가가 촘촘한 종목도 이건 공짜가 아닙니다.
- 슬리피지 — 신호가 뜨는 순간은 대개 호가창이 한쪽으로 밀리는 순간입니다. 평소 스프레드로 계산해두면 정작 필요할 때 그 값이 아닙니다.
- 펀딩비 — 보유 시간이 짧아 대부분 무시되지만, 정산 시각을 걸쳐 들고 있으면 짧은 보유 시간에 비해 비용이 큽니다. 평시 BTC 펀딩은 8시간당 0.01% 안팎이라 왕복 수수료의 10분의 1 수준이지만, 한쪽으로 쏠린 구간에서는 몇 배로 튀면서 왕복 수수료에 맞먹기도 합니다.
- 기회비용 — 지정가가 미체결로 취소되면 손실은 0이지만, 그때 진짜로 왔던 움직임도 같이 사라집니다. 이게 2편의 핵심이었습니다.
VWAP을 기준선으로 쓴다는 것
VWAP은 거래량으로 가중한 평균 체결가입니다. 이동평균이 시간에만 가중치를 주는 것과 달리, 실제로 물량이 오간 가격대에 무게가 실려요. 그래서 체결 기준선으로 쓰이고, 스캘핑에서는 가격이 VWAP에서 멀어졌다가 되돌아온다는 가정으로 씁니다. 코드를 짤 때 실제로 걸리는 지점은 두 가지입니다. 하나는 리셋 시점 — 24시간 돌아가는 코인 시장에는 장 시작이 없어서 누적 시작점을 직접 정해야 하고, 리셋 없이 전체 구간을 누적하면 데이터가 쌓일수록 선이 굳어 신호가 아예 안 뜹니다. 다른 하나는 밴드 폭인데, 이건 뒤에서 다시 얘기하겠습니다.
import pandas as pd
# 바이낸스 USDT-M 선물 1분봉 (인덱스는 UTC tz-aware DatetimeIndex)
df = pd.read_parquet("btcusdt_1m.parquet")
# 세션 VWAP: UTC 00:00마다 누적을 리셋한다.
# 리셋을 안 걸면 뒤로 갈수록 분모가 커져서 선이 굳고 신호가 사라진다.
tp = (df["high"] + df["low"] + df["close"]) / 3 # 전형가격
session = df.index.floor("D")
df["vwap"] = (tp * df["volume"]).groupby(session).cumsum() \
/ df["volume"].groupby(session).cumsum()
# VWAP 대비 편차의 세션 내 표준편차로 밴드를 만든다 (고정 폭 대신 변동성 추종)
dev = tp - df["vwap"]
df["sigma"] = dev.groupby(session).expanding().std() \
.reset_index(level=0, drop=True)
df["lower"] = df["vwap"] - 1.5 * df["sigma"]
# 신호는 반드시 직전 봉 기준으로 shift.
# 종가를 보고 그 종가에 체결시키면 백테스트가 거짓말을 시작한다.
df["long_signal"] = (tp < df["lower"]).shift(1).fillna(False).astype(bool)1편에서는 밴드를 벗어나면 시장가로 잡고, VWAP 복귀나 반대편 밴드에서 시장가로 정리했습니다. 로직 자체는 이걸로 끝입니다. 2편에서 바꾼 건 밴드 계수를 몇으로 할지가 아니라, 저 두 번의 시장가 주문을 어떻게든 없애는 쪽이었습니다.
taker를 버리는 시도
- 진입을 포스트온리 지정가로 바꿨습니다. 바이낸스에서는 timeInForce를 GTX로 주면 즉시 체결될 가격일 때 주문이 아예 거부됩니다. 실수로 taker가 될 일이 없어요. 대신 거부는 예외로 올라오니 코드에서 잡아 신호를 버리는 쪽으로 흡수해야 합니다.
- 청산도 지정가로 걸어두되, 손절만은 시장가로 남겼습니다. 손절까지 메이커로 고집하면 못 빠져나오는 상황이 생깁니다. 여기서 비용을 아끼려다 계좌가 날아갑니다.
- 최소 기대이익 필터를 넣었습니다. VWAP까지의 거리가 왕복 비용의 몇 배 이상일 때만 진입합니다. 이 필터 하나로 거래 횟수가 크게 줄었습니다.
- 밴드 계수를 넓혔습니다. 1.5에서 2.0, 2.5로 올리면 신호당 기대 폭이 커지지만 신호 자체가 귀해집니다.
- 미체결 주문에 제한시간을 뒀습니다. 일정 시간 안에 안 붙으면 취소하고 그 신호는 버립니다. 시장가로 따라가지 않는 게 규칙이었습니다.
import os, time, ccxt
# 키는 환경변수로만 읽는다. 미설정이면 여기서 바로 죽는 게 낫다.
ex = ccxt.binanceusdm({
"apiKey": os.environ["BINANCE_API_KEY"],
"secret": os.environ["BINANCE_API_SECRET"],
})
def maker_entry(symbol, side, qty, price, wait_sec=20):
"""포스트온리 지정가 진입. 붙으면 메이커, 안 붙으면 신호를 버린다."""
# GTX = post-only. 즉시 체결될 가격이면 거래소가 주문을 거부하고,
# ccxt는 이걸 InvalidOrder 계열 예외로 올린다. 안 잡으면 봇이 죽는다.
try:
order = ex.create_order(symbol, "limit", side, qty, price,
{"timeInForce": "GTX"})
except ccxt.InvalidOrder:
return None
deadline = time.time() + wait_sec
while time.time() < deadline:
o = ex.fetch_order(order["id"], symbol)
if o["status"] == "closed":
return o
time.sleep(1)
# 여기서 시장가로 따라가고 싶어진다. 따라가는 순간 taker가 되고
# 비용 구조는 1편으로 원위치된다. 그래서 그냥 취소한다.
# 단, 취소 응답의 filled 잔량은 별도로 처리해야 한다(아래 본문 참고).
canceled = ex.cancel_order(order["id"], symbol)
return canceled if canceled.get("filled") else None실제로 봇을 돌린 결과는 영상에 그대로 있습니다. 여기서는 구조만 말하면, 회당 비용은 분명히 내려갔는데 체결 표본이 같이 바뀌면서 기대했던 만큼 손익이 따라오지 않았습니다. 최소 기대이익 필터와 넓힌 밴드는 신호 품질을 올리는 대신 거래 횟수를 줄였고, 스캘핑에서 거래 횟수가 줄면 통계가 수렴할 시간도 같이 줄어듭니다. 몇 주 돌린 결과로 '이 파라미터가 낫다'고 말하기 어려운 표본이 남았다는 뜻입니다.
왜 이게 실전에서 깨지는가
- 비용은 확정값이고 엣지는 추정값입니다. 왕복 수수료는 첫 거래부터 100% 확률로 나가지만, 밴드 되돌림이 통한다는 가정은 표본이 쌓여야 확인됩니다. 스캘핑은 이 비대칭이 가장 심한 영역이고, 그래서 파라미터를 아무리 다듬어도 확정 비용 아래로는 못 내려갑니다.
- 메이커 전환은 비용을 줄이는 대신 체결 편향을 삽니다. 위 callout의 adverse selection 때문에, 백테스트에서 수수료율만 바꿔 넣은 결과와 실계좌 결과는 다른 이야기가 됩니다. 제대로 비교하려면 미체결까지 재현하는 호가창 시뮬레이션이 필요한데, 개인이 그걸 제대로 만드는 건 전략 자체보다 어렵습니다.
- 1분봉 백테스트는 봉 안에서 일어난 일을 모릅니다. 같은 봉에서 손절가와 익절가를 둘 다 스쳤을 때 어느 쪽이 먼저였는지 OHLC로는 알 수 없고, 여기서 어떻게 처리하느냐에 따라 성적표가 통째로 바뀝니다. 보유 시간이 짧을수록 이 구간의 비중이 커집니다.
- VWAP 되돌림은 레인지를 전제합니다. 추세가 나오면 가격은 밴드 밖에 머물면서 계속 갑니다. 밴드 이탈 진입은 그 상황에서 매번 반대편에 서게 되고, 손절이 시장가라면 비용도 가장 비쌀 때 나갑니다.
- API 레이턴시와 부분체결은 코드에 안 보입니다. 위 예제는 1초마다 주문 상태를 폴링하는데, 실제로는 그 사이에 가격이 지나가고 부분체결 잔량이 남습니다. 취소 응답의 체결 수량을 확인해 잔량을 처리하는 로직 없이 오래 돌리면 포지션 수량이 서서히 어긋납니다.
- 이 글과 첨부 코드는 전략 구조를 설명하려고 만든 교육·연구 자료입니다. 특정 종목이나 매매 방식을 권하는 게 아니고, 수수료율은 거래소 정책과 등급에 따라 바뀌니 실제 값은 API로 직접 조회해서 쓰세요.
첨부파일
영상에 사용된 소스코드와 설명서입니다. 로그인 없이 받을 수 있습니다.
VWAP 스캘핑 봇 소스코드 (2편)수수료 반영 백테스트 스크립트바이낸스 선물 수수료·주문옵션 정리