월가 롱숏을 코인에 옮겨봤다 — 델타 0으로 시작해 방향 베팅으로 끝났다
바이낸스 선물 헤지 모드로 롱숏 동시 진입, 한쪽 수익 시 반대쪽 청산, 트레일링 스탑까지 붙여봤습니다. 이 구조가 왜 시장중립이 아닌지 파라미터 산술로 정리했습니다.
월스트리트 데스크에서 가장 기본으로 쓴다는 게 롱숏이라길래, 그걸 바이낸스 선물에 그대로 옮겨보기로 했습니다. 롱과 숏을 동시에 들고 있으면 방향 리스크가 사라지고, 한쪽이 먼저 이기면 진 쪽을 잘라내고 이긴 쪽만 트레일링 스탑으로 끌고 간다. 말로 하면 빈틈이 없어 보입니다. 코드로 옮기고 나서 알게 된 건, 이 구조에서 실제 베팅 대상은 시장 방향이 아니라 '어느 쪽을 언제 죽일 것인가'였다는 겁니다. 영상 기준으로 02:14부터가 전략 설명, 03:24부터가 개발 구간이고, 이 글은 그 두 구간을 코드로 다시 적은 다음 08:06 수익률 화면을 어떻게 읽어야 하는지까지 붙인 겁니다.
월가의 롱숏과 내가 만든 롱숏은 다른 물건이다
월가에서 롱숏이라고 부르는 건 대개 횡단면 전략입니다. 같은 섹터 안에서 상대적으로 싼 걸 사고 비싼 걸 팔아, 섹터 공통 요인을 상쇄하고 남는 상대가치만 가져가겠다는 구조예요. 페어트레이딩이 그 극단적인 형태고, 두 자산 스프레드가 평균으로 돌아온다는 걸 공적분 검정 같은 절차로 확인한 다음에야 들어갑니다. 핵심은 롱과 숏이 서로 다른 자산이라는 점입니다. 그래야 스프레드라는 게 존재하니까요. 제가 이번에 만든 건 그게 아닙니다. 같은 심볼 하나에 롱과 숏을 동시에 겁니다. 자산이 하나면 스프레드도 없고, 평균으로 회귀시킬 대상 자체가 없습니다.
- 대상 — 월가 롱숏은 서로 다른 두 자산. 이건 같은 심볼 양방향. 후자에는 상대가치라는 개념이 성립하지 않습니다.
- 중립의 의미 — 전자는 공통 요인을 제거해 알파만 남기는 중립. 후자의 델타 0은 산술적으로 '아무것도 안 들고 있는 상태 + 수수료'와 같습니다.
- 진입 근거 — 전자는 스프레드 통계(z-score, 공적분). 후자는 진입 근거가 없습니다. 아무 시점에 열어도 진입 직후 상태는 항상 동일합니다.
- 수익의 출처 — 전자는 스프레드 수렴. 후자는 한쪽을 자른 뒤의 방향성 추세. 트레일링 스탑이 붙는 순간 이건 그냥 돌파 추종입니다.
그래서 이 구조를 정직하게 부르면 롱숏이 아니라 양방향 동시 진입입니다. 시장이 어디로 가든 한쪽은 반드시 이기고 한쪽은 반드시 집니다. 그리고 그 둘의 크기는 정확히 같습니다. 진입 시점에 계좌에서 나가는 건 수수료뿐이고, 손익이 갈리기 시작하는 건 제가 '한쪽을 죽인다'는 결정을 실행한 다음부터예요. 전략의 성패는 진입 로직이 아니라 청산 로직에 100% 걸려 있습니다.
구현 (03:24) — 헤지 모드가 전제다
여기서 처음 막혔습니다. 바이낸스 USDT-M 선물은 기본이 원웨이(one-way) 모드라, 롱을 들고 있는 상태에서 매도 주문을 내면 신규 숏 진입이 아니라 기존 롱의 청산으로 처리됩니다. 같은 심볼에 롱과 숏을 동시에 보유하려면 계정을 헤지 모드로 바꾸고, 모든 주문에 positionSide를 명시해야 합니다. 모드 변경은 해당 심볼에 포지션이나 미체결 주문이 남아 있으면 실패하니 봇 기동 전에 계좌를 비워두는 게 편합니다. 그리고 두 개의 시장가 주문은 어차피 순차로 나갑니다. 그 사이 수백 밀리초 동안은 한쪽만 열린 편도 상태예요.
import os
from binance.client import Client
# 키는 환경변수로만 읽는다. 코드나 노트북에 박아두면 그날로 계좌가 남의 것이 된다
client = Client(os.environ["BINANCE_API_KEY"], os.environ["BINANCE_API_SECRET"])
SYMBOL, QTY = "BTCUSDT", 0.01
# 헤지 모드 전제. 원웨이 모드에서는 반대 주문이 신규 진입이 아니라 기존 포지션 청산이 된다
try:
client.futures_change_position_mode(dualSidePosition="true")
except Exception as e:
print("이미 헤지 모드이거나 포지션/미체결 주문이 남아 있음:", e)
def open_both(symbol: str, qty: float):
"""같은 심볼에 롱/숏 시장가 동시 진입. 체결 직후 델타는 0, 나간 건 수수료뿐"""
long_order = client.futures_create_order(
symbol=symbol, side="BUY", positionSide="LONG", type="MARKET", quantity=qty)
short_order = client.futures_create_order(
symbol=symbol, side="SELL", positionSide="SHORT", type="MARKET", quantity=qty)
return long_order, short_order
# 두 주문 사이 수백 ms 동안은 한쪽만 열린 상태다. 이 틈에 가격이 튀면 진입가가 벌어진다
open_both(SYMBOL, QTY)한쪽을 죽이고 이긴 쪽에 트레일링을 건다
감시 루프는 단순합니다. 두 포지션의 진입가 대비 가격 변화율을 계속 재다가, 어느 한쪽이 임계치를 넘으면 반대쪽을 시장가로 정리하고 이긴 쪽에 TRAILING_STOP_MARKET 주문을 겁니다. 여기서 재는 값은 바이낸스 UI에 뜨는 ROE(증거금 대비, 레버리지 반영)가 아니라 순수 가격 변화율입니다. 둘을 헷갈리면 트리거가 레버리지 배수만큼 앞당겨지거나 밀립니다. 헤지 모드에서는 reduceOnly 대신 positionSide로 어느 포지션을 건드릴지 지정합니다. callbackRate는 퍼센트 단위로 넣고(1.0이면 1%), 허용 범위는 바이낸스 문서에서 현재 값을 확인하는 게 안전합니다. activationPrice를 생략하면 현재가 기준으로 즉시 활성화됩니다. 발동 기준 가격이 마크가격인지 최종체결가인지는 workingType으로 갈리니, 기본값에 맡기지 말고 명시하는 편을 권합니다.
TRIGGER_PCT, CALLBACK_PCT = 0.4, 1.0 # 반대쪽 청산 트리거 0.4%, 트레일링 폭 1%
def price_pnl_pct(p):
"""진입가 대비 가격 변화율(%). 레버리지 반영 전 값. 포지션이 없으면 None
청산된 쪽은 entryPrice가 "0.0"으로 내려온다. 가드 없이 나누면 그 순간 봇이 죽는다
"""
entry, mark = float(p["entryPrice"]), float(p["markPrice"])
if entry == 0 or float(p["positionAmt"]) == 0:
return None
return (1 if p["positionSide"] == "LONG" else -1) * (mark - entry) / entry * 100
def cut_loser_and_trail(symbol: str, qty: float):
"""한 번 발동하면 승자 이름을 리턴한다. 호출부는 이때 감시 루프를 끝내야 한다"""
raw = client.futures_position_information(symbol=symbol)
pos = {p["positionSide"]: p for p in raw}
for win, lose in (("LONG", "SHORT"), ("SHORT", "LONG")):
pnl = price_pnl_pct(pos.get(win, {}))
if pnl is None or pnl < TRIGGER_PCT:
continue
# 지는 쪽 시장가 청산. 헤지 모드에선 reduceOnly 대신 positionSide로 대상을 지정한다
client.futures_create_order(
symbol=symbol, side="BUY" if lose == "SHORT" else "SELL",
positionSide=lose, type="MARKET", quantity=qty)
# 이긴 쪽에 트레일링 스탑. activationPrice 생략 시 현재가에서 바로 활성화된다
client.futures_create_order(
symbol=symbol, side="SELL" if win == "LONG" else "BUY",
positionSide=win, type="TRAILING_STOP_MARKET",
quantity=qty, callbackRate=CALLBACK_PCT, workingType="MARK_PRICE")
return win
return None수익률 구간(08:06)을 어떻게 읽어야 하나
영상 08:06에 계좌 화면을 그대로 띄워뒀습니다. 그 숫자를 여기 다시 옮겨 적지는 않겠습니다. 트리거 0.4%, 콜백 1%, BTCUSDT, 그 기간이라는 조건 중 하나만 바뀌어도 재현이 안 되는 숫자라, 글에 박아두면 그때부터는 기록이 아니라 광고가 되니까요. 대신 봐야 할 건 총수익률이 아니라 사이클 하나의 모양입니다. 진 쪽 손실은 트리거 시점에 확정되고, 이긴 쪽 수익은 그 뒤에 추세가 얼마나 이어지는지에 전적으로 달려 있습니다. 그러니까 이 봇의 손익 분포는 '작고 확실한 손실 + 가끔 나오는 큰 이익'이라는 추세추종 구조를 그대로 따라갑니다. 문제는 진입 근거가 없다는 거예요. 추세추종은 최소한 추세가 시작될 법한 자리를 골라서 들어가는데, 이 봇은 아무 자리에서나 열고 시장이 대신 골라주기를 기다립니다. 횡보 구간에서 사이클이 반복되면 계좌는 구조적으로 계단을 내려갑니다. 이건 백테스트가 아니라 위 산술에서 바로 나오는 결론입니다.
왜 이게 실전에서 깨지는가
- 델타 0은 무포지션과 같습니다. 진입 자체에 기댓값이 없고, 확정 지출인 수수료만 생깁니다. 네 다리(진입 2회 + 청산 2회)가 전부 시장가라 전부 테이커고, 등급·할인에 따라 다르지만 기본 등급 테이커 0.05% 기준이면 명목가치 대비 0.2%입니다. 본인 계좌의 실제 요율로 다시 계산하세요. 승자가 이걸 넘긴 다음부터가 시작점입니다.
- 파라미터가 서로 충돌합니다. 고점이 콜백 폭에 못 미치고 꺾이면 이긴 쪽조차 손실로 끝납니다. 콜백을 줄이면 정상적인 눌림에도 조기 이탈하고, 트리거를 키우면 확정 손실이 커집니다. 이 트레이드오프를 없애는 값은 없고, 어느 쪽 실패를 감당할지 고르는 것뿐입니다.
- 헤지 모드에서는 양쪽 포지션이 각각 증거금을 잡습니다. 같은 자본으로 들 수 있는 크기가 절반으로 줄고, 급변 시 한쪽의 미실현 손실이 유지증거금을 건드리면 원하지 않는 타이밍에 강제로 정리됩니다. 산정 방식은 계좌 모드와 종목에 따라 다르니 실계좌에서 직접 확인하세요.
- 두 시장가 주문은 순차로 나갑니다. 그 사이에 가격이 튀면 진입가가 벌어져서 시작부터 델타가 0이 아닙니다. 더 나쁜 건 한쪽 청산 주문이 실패했을 때예요. 양방향으로 남은 채 방치되면 봇은 계속 감시만 하고 아무것도 안 합니다. 주문 응답 확인과 실패 시 재시도는 옵션이 아니라 필수입니다.
- 청산된 쪽의 포지션 정보는 entryPrice가 0으로 내려옵니다. 감시 루프가 그대로 계속 돌면 0으로 나누다가 봇이 죽습니다. 발동 후 루프 종료와 0 가드, 둘 다 넣어야 합니다.
- 트레일링 스탑은 거래소 서버가 추적합니다. 기준 가격과 실제 체결가는 다르고, 급락장에서는 콜백 폭보다 훨씬 아래에서 체결될 수 있습니다. 백테스트에서 콜백 1%를 1% 손실로 계산했다면 그 백테스트는 실전보다 낙관적입니다.
- 제목의 '월가 전략'은 검증 대상이지 결론이 아닙니다. 진짜 횡단면 롱숏으로 가려면 서로 다른 두 자산과 스프레드 통계가 필요하고, 그쪽은 그쪽대로 백테스트 착시 문제가 따로 있습니다. 이 시리즈 [2]에서 이어서 다룹니다.
- 이 글과 첨부 코드는 전략 구조를 설명하기 위한 교육·연구 자료입니다. 특정 종목이나 매매를 권하지 않고, 실계좌 적용 전 반드시 테스트넷에서 주문 흐름을 먼저 확인하세요.
첨부파일
영상에 사용된 소스코드와 설명서입니다. 로그인 없이 받을 수 있습니다.
롱숏 동시진입 봇 소스코드트리거·콜백 손익분기 계산기헤지 모드 운영 체크리스트