shud.log
바이브코딩 기록

봇이 팔 수 없었던 이유 — Variational 스테일 주문 버그 수정기

2026-05-01variational핑퐁버그수정자동매매

Variational 핑퐁봇이 매도를 안 하고 있었다.

사이클은 정상적으로 돌고 있었다. 로그에도 오류가 없었다. 그런데 체결이 없었다. 확인해보니 이유는 단순했다. 매도 주문이 $77,860에 걸려있는데 BTC 현재가는 $75,700이었다. 가격이 $2,160 아래로 내려간 상태에서 봇은 아무것도 모르고 사이클만 돌리고 있었다.

핑퐁봇의 작동 방식

핑퐁봇은 현재가 기준으로 위아래에 매수·매도 주문을 동시에 걸어두고, 한쪽이 체결되면 반대편 주문을 새로 배치하는 방식이다.

현재가 $77,000
  ├─ SELL @ $77,231 (현재가 + 0.3%)
  └─ BUY  @ $76,769 (현재가 - 0.3%)

BUY 체결 → SELL @ $77,000 (체결가 + 0.3%) 재배치
SELL 체결 → BUY @ $76,538 (체결가 - 0.3%) 재배치

가격이 좁은 범위에서 오르내리는 횡보 구간에서는 잘 작동한다. 문제는 가격이 한 방향으로 크게 움직일 때다.

뭐가 문제였나

BTC가 $77,400에서 $75,700으로 약 2.2% 하락했다.

봇은 $77,396에서 매수가 체결된 후 $77,860에 매도 주문을 걸었다. 그 사이에 가격이 크게 떨어졌다. $77,860짜리 매도 주문은 현재가보다 $2,160 위에 걸려있는 상태가 됐다.

봇은 이 주문이 "아직 체결되지 않은 정상 주문"이라고 인식한다. 새로 주문을 배치할 이유가 없다. 가격이 $77,860까지 회복되길 기다리는 것이다.

상태:
  열린 매도 주문: $77,860 ← 현재가보다 2.3% 위
  현재가: $75,700

봇 판단: "아직 체결 안 됐으니 기다리자"
실제 상황: 가격이 회복되지 않으면 영원히 대기

스톱로스 로직도 있었다. entry 대비 2% 이상 역행하면 포지션을 청산하는 코드다. 그런데 이것도 실행이 안 됐다. Variational API에서 포지션 조회가 제대로 되지 않으면 스톱로스가 발동하지 않는 구조였다.

결국 두 가지 안전장치가 모두 작동하지 않았다.

해결 방법

스테일 주문 감지 함수를 추가했다.

열린 주문의 가격이 현재가 대비 1.5% 이상 벗어나면 "스테일(stale, 오래된) 주문"으로 판단하고 취소한 뒤 현재가 기준으로 재배치한다.

STALE_THRESHOLD = 0.015  # 1.5%

def check_stale_orders(client, mid):
    astate = get_astate(client.label)
    open_orders = astate.get("open_orders", [])

    sell_orders = [o for o in open_orders if not o["is_buy"]]
    buy_orders  = [o for o in open_orders if o["is_buy"]]

    # 매도 주문이 현재가보다 1.5% 이상 위에 있으면 재센터
    if sell_orders:
        min_sell = min(o["price"] for o in sell_orders)
        if min_sell > mid * (1 + STALE_THRESHOLD):
            drift = (min_sell - mid) / mid * 100
            send_telegram(f"🔄 스테일 주문 감지\n매도 ${min_sell:,.1f} vs 현재 ${mid:,.1f} ({drift:.1f}% 괴리)")
            client.cancel_all_orders()
            astate["open_orders"] = []
            save_state()
            return True  # 이후 run_cycle에서 place_initial_pair 호출

    # 매수 주문이 현재가보다 1.5% 이상 아래에 있어도 동일 처리
    if buy_orders:
        max_buy = max(o["price"] for o in buy_orders)
        if max_buy < mid * (1 - STALE_THRESHOLD):
            client.cancel_all_orders()
            astate["open_orders"] = []
            save_state()
            return True

    return False

run_cycle()에서 체결 감지 이후에 호출한다.

def run_cycle():
    for client in clients:
        mid = client.get_mid_price(COIN)

        check_stop_loss(client, mid)
        check_fills(client)

        # 스테일 주문 체크 추가
        if check_stale_orders(client, mid):
            continue

        if not astate.get("open_orders"):
            place_initial_pair(client, mid)

check_stale_ordersTrue를 반환하면 open_orders가 비어있으므로 다음 사이클에서 place_initial_pair가 자동으로 현재가 기준 새 주문을 배치한다.

재센터의 트레이드오프

스테일 주문을 취소하고 재센터하면 포지션 상태가 리셋된다.

이게 항상 좋은 건 아니다. 만약 가격이 다시 $77,860까지 회복된다면, 기다린 쪽이 더 나은 결과였을 수 있다. 하지만 핑퐁봇의 목적은 좁은 구간에서 체결 횟수를 최대화하는 것이다. $2,000 위에 걸린 주문을 기다리는 동안 봇은 아무것도 못 하고 있다. 기회비용이 더 크다.

1.5%라는 기준도 임의적이다. GAP_PCT가 0.3%이니까 1.5%면 갭의 5배다. 이 정도 벗어났으면 정상적인 핑퐁 범위를 벗어난 것으로 판단한다.

배운 것

핑퐁봇을 만들 때 "가격이 크게 움직이면 어떻게 되는가"를 미리 생각해야 한다.

체결 로직, 수익 계산, 스톱로스 — 여기까지는 만들었다. 그런데 "주문이 현재가 범위를 벗어난 채로 방치된 상태"는 생각하지 못했다. 봇이 멀쩡히 돌아가고 있으니 문제가 없다고 착각했다.

사이클이 돌아간다는 것과 봇이 제대로 작동하고 있다는 것은 다른 말이다.

이어서 읽기
← 목록으로