월가에서 쓰는 롱숏 전략 — 델타 중립에서 방향 베팅으로
바이낸스 선물 헤지 모드로 롱숏 동시 진입, 한쪽 수익 시 반대쪽 청산, 트레일링 스탑까지 붙여봤습니다. 이 구조가 왜 시장중립이 아닌지 파라미터 산술로 정리한 기록입니다.
월스트리트 데스크에서 가장 기본으로 쓴다는 게 롱숏이라길래, 그걸 바이낸스 선물에 그대로 옮겨보기로 했습니다. 롱과 숏을 동시에 들고 있으면 방향 리스크가 사라지고, 한쪽이 먼저 이기면 진 쪽을 잘라내고 이긴 쪽만 트레일링 스탑으로 끌고 간다는, 말로 하면 빈틈이 없어 보이는 전략입니다. 이 글은 그 정리본에 영상에는 안 넣은 최소 실행 코드를 붙인 것입니다. 결론부터 말하면, 이 구조에서 실제 베팅 대상은 시장 방향이 아니라 '어느 쪽을 언제 죽일 것인가'라는 청산 타이밍이었습니다.
월가의 롱숏과 내가 만든 롱숏은 다른 물건이다
월가에서 롱숏이라고 부르는 건 대개 횡단면 전략입니다. 같은 섹터 안에서 상대적으로 싼 걸 사고 비싼 걸 팔아, 섹터 공통 요인을 상쇄하고 남는 상대가치만 가져가겠다는 구조예요. 페어트레이딩이 그 극단적인 형태고, 두 자산 스프레드가 평균으로 돌아온다는 걸 공적분 검정 같은 절차로 확인한 다음에야 들어갑니다. 핵심은 롱과 숏이 서로 다른 자산이라는 점입니다. 그래야 스프레드라는 게 존재하니까요. 제가 이번에 만든 건 그게 아닙니다.
- 대상: 월가 롱숏은 서로 다른 두 자산, 이건 같은 심볼 BTCUSDT의 양방향입니다. 후자에는 상대가치라는 개념이 성립하지 않습니다.
- 중립의 의미: 전자는 공통 요인을 제거해 알파만 남기는 중립, 후자의 델타 0은 산술적으로 '아무것도 안 들고 있는 상태 + 수수료'와 같습니다.
- 진입 근거: 전자는 스프레드 통계(z-score). 후자는 진입 근거가 없습니다. 아무 시점에 열어도 진입 직후 상태는 항상 동일하거든요.
- 수익의 출처: 전자는 스프레드 수렴. 후자는 한쪽을 자른 뒤의 방향성 추세입니다. 트레일링 스탑이 붙는 순간 이건 그냥 돌파 추종입니다.
그래서 이 구조를 정직하게 부르면 롱숏이 아니라 '양방향 동시 진입 후 한쪽 청산'입니다. 시장이 어디로 가든 한쪽은 반드시 이기고 한쪽은 반드시 집니다. 그리고 그 둘의 크기는 정확히 같습니다. 진입 시점에 계좌에서 나가는 건 수수료뿐이고, 손익이 갈리기 시작하는 건 제가 '한쪽을 죽인다'는 결정을 실행한 다음부터예요. 전략의 성패는 진입 로직이 아니라 청산 로직에 100% 걸려 있습니다.
헤지 모드가 모든 것의 전제다
여기서 처음 막혔습니다. 바이낸스 USDT-M 선물은 기본이 원웨이(one-way) 모드라, 롱을 들고 있는 상태에서 매도 주문을 내면 신규 숏 진입이 아니라 기존 롱의 청산으로 처리됩니다. 같은 심볼에 롱과 숏을 동시에 보유하려면 계정을 헤지 모드로 바꾸고, 모든 주문에 positionSide를 명시해야 합니다. 모드 변경은 해당 심볼에 포지션이나 미체결 주문이 남아 있으면 실패하니 봇 기동 전에 계좌를 비워두는 게 편합니다. 그리고 두 개의 시장가 주문은 어차피 순차로 나갑니다. 그 사이 수백 밀리초 동안은 한쪽만 열린 편도 상태예요.
// 실거래(dry_run=false)로 바꾸면 가장 먼저 헤지모드부터 켠다.
// 계정에 포지션이나 미체결 주문이 있으면 실패하니 미리 비워둬야 한다.
ex.SetHedgeMode();
ex.SetLeverage(cfg.Symbol, cfg.Leverage);
// ... 진입 조건 충족 시 ...
string qtyString = FmtQty(qty, step);
// 헷지 모드에서는 주문에 positionSide를 붙여 방향을 지정한다.
// BUY + LONG = 롱 포지션 신규 진입
// SELL + SHORT = 숏 포지션 신규 진입
// 이 두 주문은 순차로 나가므로, 그 사이 밀리초 동안은 한쪽만 열린 상태다.
ex.MarketOrder(cfg.Symbol, "BUY", "LONG", qtyString);
ex.MarketOrder(cfg.Symbol, "SELL", "SHORT", qtyString);
// 신규 진입이 아니라 청산이라면?
// SELL + LONG = 롱 포지션 청산
// BUY + SHORT = 숏 포지션 청산
// 헷지모드는 reduceOnly를 못쓰고 이렇게 방향으로 구분한다.승패가 갈리면 이긴 쪽만 끌고 간다
감시 루프는 단순합니다. 두 포지션의 진입가 대비 가격 변화율을 계속 재다가, 어느 한쪽이 임계치를 넘으면 반대쪽을 시장가로 정리하고 이긴 쪽을 트레일링으로 끌고 갑니다. 여기서 재는 값은 바이낸스 UI에 뜨는 ROE(증거금 대비, 레버리지 반영)가 아니라 순수 가격 변화율입니다. 둘을 헷갈리면 트리거가 레버리지 배수만큼 앞당겨지거나 밀립니다. 트레일링은 거래소 서버에 맡기는 대신 봇에서 직접 최고점을 추적하다가 시장가로 던지는 방식을 썼습니다.
// ---------- 둘 다 열림: 방향 대기 (state == BOTH) ----------
double longPct = (mid - entryLong) / entryLong * 100.0;
double shortPct = (entryShort - mid) / entryShort * 100.0;
// 이긴 쪽이 목표 수익률(TargetProfitPct)에 닿았나?
if (longPct >= cfg.TargetProfitPct || shortPct >= cfg.TargetProfitPct)
{
// 그럼 진 쪽을 시장가로 청산한다.
winnerIsLong = longPct >= shortPct;
if (winnerIsLong)
// 진 쪽 = 숏. 숏 청산 = BUY + SHORT
ex.MarketOrder(cfg.Symbol, "BUY", "SHORT", FmtQty(qty, step));
else
// 진 쪽 = 롱. 롱 청산 = SELL + LONG
ex.MarketOrder(cfg.Symbol, "SELL", "LONG", FmtQty(qty, step));
// 최고 수익률을 기록하고, 이긴 쪽만 남은 상태(ONE)로 바꾼다.
peakPct = winnerIsLong ? longPct : shortPct;
state = ONE;
}
// ---------- 이긴 쪽만 남음: 트레일링 (state == ONE) ----------
double curPct = winnerIsLong ? longPct : shortPct;
if (curPct > peakPct) peakPct = curPct; // 최고점 갱신
// 최고점에서 trailing_pct 만큼 빠지거나, 손절선을 건드리면
if (curPct <= peakPct - cfg.TrailingPct || curPct <= -cfg.StopLossPct)
{
// 남은 이긴 쪽 포지션도 마저 청산하고 라운드를 끝낸다.
if (winnerIsLong) ex.MarketOrder(cfg.Symbol, "SELL", "LONG", FmtQty(qty, step));
else ex.MarketOrder(cfg.Symbol, "BUY", "SHORT", FmtQty(qty, step));
state = FLAT; // 모든 게 끝났으니 다시 진입 대기 상태로.
}손익의 모양은 파라미터가 결정한다
영상에 계좌 화면을 그대로 띄워뒀습니다. 그 숫자를 여기 다시 옮겨 적지는 않겠습니다. 트리거 0.4%, 트레일링 0.2%, BTCUSDT, 그 기간이라는 조건 중 하나만 바뀌어도 재현이 안 되는 숫자라, 글에 박아두면 그때부터는 기록이 아니라 광고가 되니까요. 대신 봐야 할 건 총수익률이 아니라 사이클 하나의 모양입니다. 진 쪽 손실은 트리거 시점에 확정되고, 이긴 쪽 수익은 그 뒤에 추세가 얼마나 이어지는지에 전적으로 달려 있습니다. 그러니까 이 봇의 손익 분포는 '작고 확실한 손실 + 가끔 나오는 큰 이익'이라는 추세추종 구조를 그대로 따라갑니다.
dry_run으로 실시간 호가에 붙여 며칠 돌려보면 이게 눈에 그대로 보입니다. 변동성이 죽은 구간에서는 방향이 안 나와 타임아웃으로 양쪽을 접는 라운드가 반복되는데, 이런 라운드는 예외 없이 왕복 수수료 + 반값 스프레드만큼 마이너스로 닫힙니다. 문제는 진입 근거가 없다는 거예요. 추세추종은 최소한 추세가 시작될 법한 자리를 골라서 들어가는데, 이 봇은 아무 자리에서나 열고 시장이 대신 골라주기를 기다립니다. 횡보 구간에서 사이클이 반복되면 계좌는 구조적으로 계단을 내려갑니다. 이건 백테스트가 아니라 그냥 산수입니다.
왜 이게 실전에서 깨지는가
- 델타 0은 무포지션과 같습니다. 진입 자체에 기댓값이 없고, 확정 지출인 수수료만 생깁니다. 네 다리(진입 2회 + 청산 2회)가 전부 시장가라 전부 테이커고, 기본 등급이면 명목가치 대비 0.2%에 육박합니다.
- 파라미터가 서로 충돌합니다. 고점이 트레일링 폭에 못 미치고 꺾이면 이긴 쪽조차 손실로 끝납니다. 트레일링을 줄이면 정상적인 눌림에도 조기 이탈하고, 트리거를 키우면 확정 손실이 커집니다. 어느 쪽 실패를 감당할지 고르는 것뿐입니다.
- 헤지 모드에서는 양쪽 포지션이 각각 증거금을 잡습니다. 같은 자본으로 들 수 있는 크기가 절반으로 줄고, 급변 시 한쪽의 미실현 손실이 유지증거금을 건드리면 원하지 않는 타이밍에 강제로 정리됩니다.
- 두 시장가 주문은 순차로 나갑니다. 그 사이에 가격이 튀면 진입가가 벌어져서 시작부터 델타가 0이 아닙니다. 더 나쁜 건 한쪽 청산 주문이 실패했을 때예요. 이 봇은 실패 시 자동 재시도까지는 넣지 않았습니다.
- 로컬 상태와 거래소 실제 포지션이 어긋날 수 있습니다. 이 봇은 진입가를 로컬 변수로 들고 있는데, 부분체결이나 주문 실패로 로컬 상태가 실제와 틀어지면 그게 새로운 위험입니다.
- 트레일링은 급락장에서 콜백 폭보다 훨씬 아래에서 체결됩니다. 서버측 주문이든 로컬 추적이든 결국 시장가로 나가고, 기준 가격과 실제 체결가는 다릅니다. 백테스트에서 트레일링 0.2%를 0.2% 손실로 계산했다면 그 백테스트는 실전보다 낙관적입니다.
- 제목의 '월가 전략'은 검증 대상이지 결론이 아닙니다. 진짜 횡단면 롱숏으로 가려면 서로 다른 두 자산과 스프레드 통계가 필요하고, 그쪽은 그쪽대로 백테스트 착시 문제가 따로 있습니다.
이 글과 첨부 코드는 전략 구조를 설명하기 위한 교육·연구 자료입니다. 특정 종목이나 매매를 권하지 않고, 실계좌 적용 전 반드시 dry_run과 테스트넷에서 주문 흐름을 먼저 확인하세요.
첨부파일
영상에 사용된 소스코드와 설명서입니다. 로그인 없이 받을 수 있습니다.
롱숏 동시진입 봇 C# 소스 일체